From 8495b0fc079a6cb9908951b9350cd149d3612a1c Mon Sep 17 00:00:00 2001 From: Sadra Barikbin Date: Thu, 29 Jun 2023 10:51:27 +0330 Subject: [PATCH] Slight change in early teardown solution Fix some bugs and do some improvements and refactors --- src/_pytest/fixtures.py | 300 ++++++++++------ src/_pytest/python.py | 51 ++- testing/python/fixtures.py | 701 ++++++++++++++++++++++++++++++++++--- testing/python/metafunc.py | 2 + 4 files changed, 899 insertions(+), 155 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index e42119711..cfc153b4d 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -225,13 +225,19 @@ class FixtureArgKey: param_value: Optional[Hashable] scoped_item_path: Optional[Path] item_cls: Optional[type] + baseid: Optional[str] -def get_fixture_arg_key(item: nodes.Item, argname: str, scope: Scope) -> FixtureArgKey: +def get_fixture_arg_key( + item: nodes.Item, + argname: str, + scope: Scope, + baseid: Optional[str] = None, + is_parametrized: Optional[bool] = False, +) -> FixtureArgKey: param_index = None param_value = None - if hasattr(item, "callspec") and argname in item.callspec.params: - # Fixture is parametrized. + if is_parametrized: if isinstance(item.callspec.params[argname], Hashable): param_value = item.callspec.params[argname] else: @@ -251,7 +257,9 @@ def get_fixture_arg_key(item: nodes.Item, argname: str, scope: Scope) -> Fixture else: item_cls = None - return FixtureArgKey(argname, param_index, param_value, scoped_item_path, item_cls) + return FixtureArgKey( + argname, param_index, param_value, scoped_item_path, item_cls, baseid + ) def get_fixture_keys(item: nodes.Item, scope: Scope) -> Iterator[FixtureArgKey]: @@ -259,17 +267,27 @@ def get_fixture_keys(item: nodes.Item, scope: Scope) -> Iterator[FixtureArgKey]: the specified scope.""" assert scope is not Scope.Function if hasattr(item, "_fixtureinfo"): - # sort this so that different calls to - # get_fixture_keys will be deterministic. - for argname, fixture_def in sorted(item._fixtureinfo.name2fixturedefs.items()): - # In the case item is parametrized on the `argname` with - # a scope, it overrides that of the fixture. - if hasattr(item, "callspec") and argname in item.callspec._arg2scope: - if item.callspec._arg2scope[argname] != scope: - continue - elif fixture_def[-1]._scope != scope: - continue - yield get_fixture_arg_key(item, argname, scope) + for argname in item._fixtureinfo.names_closure: + is_parametrized = ( + hasattr(item, "callspec") and argname in item.callspec._arg2scope + ) + for i in reversed( + range(item._fixtureinfo.name2num_fixturedefs_used[argname]) + ): + fixturedef = item._fixtureinfo.name2fixturedefs[argname][-(i + 1)] + # In the case item is parametrized on the `argname` with + # a scope, it overrides that of the fixture. + if (is_parametrized and item.callspec._arg2scope[argname] != scope) or ( + not is_parametrized and fixturedef._scope != scope + ): + break + yield get_fixture_arg_key( + item, + argname, + scope, + None if fixturedef.is_pseudo else fixturedef.baseid, + is_parametrized, + ) # Algorithm for sorting on a per-parametrized resource setup basis. @@ -293,17 +311,31 @@ def reorder_items(items: Sequence[nodes.Item]) -> List[nodes.Item]: for key in keys: item_d[key].append(item) items_dict = dict.fromkeys(items, None) + last_item_by_argkey: Dict[FixtureArgKey, nodes.Item] = {} reordered_items = list( - reorder_items_atscope(items_dict, argkeys_cache, items_by_argkey, Scope.Session) + reorder_items_atscope( + items_dict, + argkeys_cache, + items_by_argkey, + last_item_by_argkey, + Scope.Session, + ) ) for scope in reversed(HIGH_SCOPES): for key in items_by_argkey[scope]: - last_item_dependent_on_key = items_by_argkey[scope][key].pop() - fixturedef = last_item_dependent_on_key._fixtureinfo.name2fixturedefs[ - key.argname - ][-1] - if fixturedef.is_pseudo: + last_item_dependent_on_key = last_item_by_argkey[key] + if key.baseid is None: continue + for i in range( + last_item_dependent_on_key._fixtureinfo.name2num_fixturedefs_used[ + key.argname + ] + ): + fixturedef = last_item_dependent_on_key._fixtureinfo.name2fixturedefs[ + key.argname + ][-(i + 1)] + if fixturedef.baseid == key.baseid: + break last_item_dependent_on_key.teardown = functools.partial( lambda other_finalizers, new_finalizer: [ finalizer() for finalizer in (new_finalizer, other_finalizers) @@ -330,20 +362,28 @@ def fix_cache_order( if key in ignore: continue items_by_argkey[scope][key].appendleft(item) - # Make sure last dependent item on a key - # remains updated while reordering. - if items_by_argkey[scope][key][-1] == item: - items_by_argkey[scope][key].pop() def reorder_items_atscope( items: Dict[nodes.Item, None], argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[FixtureArgKey, None]]], items_by_argkey: Dict[Scope, Dict[FixtureArgKey, "Deque[nodes.Item]"]], + last_item_by_argkey: Dict[FixtureArgKey, nodes.Item], scope: Scope, ) -> Dict[nodes.Item, None]: - if scope is Scope.Function or len(items) < 3: + if scope is Scope.Function: return items + elif len(items) < 3: + for item in items: + for key in argkeys_cache[scope].get(item, []): + last_item_by_argkey[key] = item + return reorder_items_atscope( + items, + argkeys_cache, + items_by_argkey, + last_item_by_argkey, + scope.next_lower(), + ) ignore: Set[Optional[FixtureArgKey]] = set() items_deque = deque(items) items_done: Dict[nodes.Item, None] = {} @@ -363,20 +403,25 @@ def reorder_items_atscope( no_argkey_group[item] = None else: slicing_argkey, _ = argkeys.popitem() - # We don't have to remove relevant items from later in the # deque because they'll just be ignored. - matching_items = [ - i for i in scoped_items_by_argkey[slicing_argkey] if i in items - ] - for i in reversed(matching_items): + unique_matching_items = dict.fromkeys(scoped_items_by_argkey[slicing_argkey]) + for i in reversed(unique_matching_items if sys.version_info.minor > 7 else list(unique_matching_items)): + if i not in items: + continue fix_cache_order(i, argkeys_cache, items_by_argkey, ignore, scope) items_deque.appendleft(i) break if no_argkey_group: no_argkey_group = reorder_items_atscope( - no_argkey_group, argkeys_cache, items_by_argkey, scope.next_lower() + no_argkey_group, + argkeys_cache, + items_by_argkey, + last_item_by_argkey, + scope.next_lower(), ) for item in no_argkey_group: + for key in scoped_argkeys_cache.get(item, []): + last_item_by_argkey[key] = item items_done[item] = None ignore.add(slicing_argkey) return items_done @@ -384,7 +429,13 @@ def reorder_items_atscope( @dataclasses.dataclass class FuncFixtureInfo: - __slots__ = ("argnames", "initialnames", "names_closure", "name2fixturedefs") + __slots__ = ( + "argnames", + "initialnames", + "names_closure", + "name2fixturedefs", + "name2num_fixturedefs_used", + ) # Original function argument names. argnames: Tuple[str, ...] @@ -394,6 +445,7 @@ class FuncFixtureInfo: initialnames: Tuple[str, ...] names_closure: List[str] name2fixturedefs: Dict[str, Sequence["FixtureDef[Any]"]] + name2num_fixturedefs_used: Dict[str, int] def prune_dependency_tree(self) -> None: """Recompute names_closure from initialnames and name2fixturedefs. @@ -401,26 +453,11 @@ class FuncFixtureInfo: Can only reduce names_closure, which means that the new closure will always be a subset of the old one. The order is preserved. - This method is needed because direct parametrization may shadow some - of the fixtures that were included in the originally built dependency + This method is needed because dynamic direct parametrization may shadow + some of the fixtures that were included in the originally built dependency tree. In this way the dependency tree can get pruned, and the closure of argnames may get reduced. """ - closure: Set[str] = set() - working_set = set(self.initialnames) - while working_set: - argname = working_set.pop() - # Argname may be smth not included in the original names_closure, - # in which case we ignore it. This currently happens with pseudo - # FixtureDefs which wrap 'get_direct_param_fixture_func(request)'. - # So they introduce the new dependency 'request' which might have - # been missing in the original tree (closure). - if argname not in closure and argname in self.names_closure: - closure.add(argname) - if argname in self.name2fixturedefs: - working_set.update(self.name2fixturedefs[argname][-1].argnames) - - self.names_closure[:] = sorted(closure, key=self.names_closure.index) class FixtureRequest: @@ -1407,6 +1444,26 @@ def pytest_addoption(parser: Parser) -> None: ) +def _get_direct_parametrize_args(node: nodes.Node) -> List[str]: + """Return all direct parametrization arguments of a node, so we don't + mistake them for fixtures. + + Check https://github.com/pytest-dev/pytest/issues/5036. + + These things are done later as well when dealing with parametrization + so this could be improved. + """ + parametrize_argnames: List[str] = [] + for marker in node.iter_markers(name="parametrize"): + if not marker.kwargs.get("indirect", False): + p_argnames, _ = ParameterSet._parse_parametrize_args( + *marker.args, **marker.kwargs + ) + parametrize_argnames.extend(p_argnames) + + return parametrize_argnames + + class FixtureManager: """pytest fixture definitions and information is stored and managed from this class. @@ -1452,25 +1509,6 @@ class FixtureManager: } session.config.pluginmanager.register(self, "funcmanage") - def _get_direct_parametrize_args(self, node: nodes.Node) -> List[str]: - """Return all direct parametrization arguments of a node, so we don't - mistake them for fixtures. - - Check https://github.com/pytest-dev/pytest/issues/5036. - - These things are done later as well when dealing with parametrization - so this could be improved. - """ - parametrize_argnames: List[str] = [] - for marker in node.iter_markers(name="parametrize"): - if not marker.kwargs.get("indirect", False): - p_argnames, _ = ParameterSet._parse_parametrize_args( - *marker.args, **marker.kwargs - ) - parametrize_argnames.extend(p_argnames) - - return parametrize_argnames - def getfixtureinfo( self, node: nodes.Node, func, cls, funcargs: bool = True ) -> FuncFixtureInfo: @@ -1482,12 +1520,27 @@ class FixtureManager: usefixtures = tuple( arg for mark in node.iter_markers(name="usefixtures") for arg in mark.args ) - initialnames = usefixtures + argnames - fm = node.session._fixturemanager - initialnames, names_closure, arg2fixturedefs = fm.getfixtureclosure( - initialnames, node, ignore_args=self._get_direct_parametrize_args(node) + initialnames = tuple( + dict.fromkeys( + tuple(self._getautousenames(node.nodeid)) + usefixtures + argnames + ) + ) + + arg2fixturedefs: Dict[str, Sequence[FixtureDef[Any]]] = {} + names_closure, arg2num_fixturedefs_used = self.getfixtureclosure( + node, + initialnames, + arg2fixturedefs, + self, + ignore_args=_get_direct_parametrize_args(node), + ) + return FuncFixtureInfo( + argnames, + initialnames, + names_closure, + arg2fixturedefs, + arg2num_fixturedefs_used, ) - return FuncFixtureInfo(argnames, initialnames, names_closure, arg2fixturedefs) def pytest_plugin_registered(self, plugin: _PluggyPlugin) -> None: nodeid = None @@ -1518,12 +1571,14 @@ class FixtureManager: if basenames: yield from basenames + @staticmethod def getfixtureclosure( - self, - fixturenames: Tuple[str, ...], parentnode: nodes.Node, + initialnames: Tuple[str], + arg2fixturedefs: Dict[str, Sequence[FixtureDef[Any]]], + fixturemanager: Optional["FixtureManager"] = None, ignore_args: Sequence[str] = (), - ) -> Tuple[Tuple[str, ...], List[str], Dict[str, Sequence[FixtureDef[Any]]]]: + ) -> Tuple[List[str], Dict[str, List[FixtureDef[Any]]]]: # Collect the closure of all fixtures, starting with the given # fixturenames as the initial set. As we have to visit all # factory definitions anyway, we also return an arg2fixturedefs @@ -1532,44 +1587,74 @@ class FixtureManager: # (discovering matching fixtures for a given name/node is expensive). parentid = parentnode.nodeid - fixturenames_closure = list(self._getautousenames(parentid)) - - def merge(otherlist: Iterable[str]) -> None: - for arg in otherlist: - if arg not in fixturenames_closure: - fixturenames_closure.append(arg) - - merge(fixturenames) + fixturenames_closure: Dict[str, int] = {} # At this point, fixturenames_closure contains what we call "initialnames", # which is a set of fixturenames the function immediately requests. We # need to return it as well, so save this. - initialnames = tuple(fixturenames_closure) - arg2fixturedefs: Dict[str, Sequence[FixtureDef[Any]]] = {} - lastlen = -1 - while lastlen != len(fixturenames_closure): - lastlen = len(fixturenames_closure) - for argname in fixturenames_closure: - if argname in ignore_args: - continue - if argname in arg2fixturedefs: - continue - fixturedefs = self.getfixturedefs(argname, parentid) - if fixturedefs: - arg2fixturedefs[argname] = fixturedefs - merge(fixturedefs[-1].argnames) + arg2num_fixturedefs_used: Dict[str, int] = defaultdict(lambda: 0) + arg2num_def_used_in_path: Dict[str, int] = defaultdict(lambda: 0) + nodes_in_fixture_tree: Deque[Tuple[str, bool]] = deque( + [(name, name != initialnames[-1]) for name in initialnames] + ) + nodes_in_path: Deque[Tuple[str, bool]] = deque() - def sort_by_scope(arg_name: str) -> Scope: + while nodes_in_fixture_tree: + node, has_sibling = nodes_in_fixture_tree.popleft() + if node not in fixturenames_closure or fixturenames_closure[node][0] > len( + nodes_in_path + ): + fixturenames_closure[node] = ( + len(nodes_in_path), + len(fixturenames_closure), + ) + if node not in arg2fixturedefs: + if node not in ignore_args: + fixturedefs = fixturemanager.getfixturedefs(node, parentid) + if fixturedefs: + arg2fixturedefs[node] = fixturedefs + if node in arg2fixturedefs: + def_index = arg2num_def_used_in_path[node] + 1 + if ( + def_index <= len(arg2fixturedefs[node]) + and def_index > arg2num_fixturedefs_used[node] + ): + arg2num_fixturedefs_used[node] = def_index + fixturedef = arg2fixturedefs[node][-def_index] + if fixturedef.argnames: + nodes_in_path.append((node, has_sibling)) + arg2num_def_used_in_path[node] += 1 + nodes_in_fixture_tree.extendleft( + [ + (argname, argname != fixturedef.argnames[-1]) + for argname in reversed(fixturedef.argnames) + ] + ) + continue + while not has_sibling: + try: + node, has_sibling = nodes_in_path.pop() + except IndexError: + assert len(nodes_in_fixture_tree) == 0 + break + arg2num_def_used_in_path[node] -= 1 + + fixturenames_closure_list = list(fixturenames_closure) + + def sort_by_scope_depth_and_arrival(arg_name: str) -> Scope: + depth, arrival = fixturenames_closure[arg_name] try: fixturedefs = arg2fixturedefs[arg_name] except KeyError: - return Scope.Function + return (Scope.Function, -depth, -arrival) else: - return fixturedefs[-1]._scope + return (fixturedefs[-1]._scope, -depth, -arrival) - fixturenames_closure.sort(key=sort_by_scope, reverse=True) - return initialnames, fixturenames_closure, arg2fixturedefs + fixturenames_closure_list.sort( + key=sort_by_scope_depth_and_arrival, reverse=True + ) + return fixturenames_closure_list, arg2num_fixturedefs_used def pytest_generate_tests(self, metafunc: "Metafunc") -> None: """Generate new tests based on parametrized fixtures used by the given metafunc""" @@ -1744,6 +1829,13 @@ class FixtureManager: def _matchfactories( self, fixturedefs: Iterable[FixtureDef[Any]], nodeid: str ) -> Iterator[FixtureDef[Any]]: + """Yields the visible fixturedefs to a node with the given id + from among the specified fixturedefs. + + :param Iterable[FixtureDef] fixturedefs: The list of specified fixturedefs. + :param str nodeid: Full node id of the node. + :rtype: Iterator[FixtureDef] + """ parentnodeids = set(nodes.iterparentnodeids(nodeid)) for fixturedef in fixturedefs: if fixturedef.baseid in parentnodeids: diff --git a/src/_pytest/python.py b/src/_pytest/python.py index e7929a2c0..40befca7b 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -58,7 +58,9 @@ from _pytest.config.argparsing import Parser from _pytest.deprecated import check_ispytest from _pytest.deprecated import INSTANCE_COLLECTOR from _pytest.deprecated import NOSE_SUPPORT_METHOD +from _pytest.fixtures import _get_direct_parametrize_args from _pytest.fixtures import FixtureDef +from _pytest.fixtures import FixtureManager from _pytest.fixtures import FixtureRequest from _pytest.fixtures import FuncFixtureInfo from _pytest.fixtures import get_scope_node @@ -388,6 +390,26 @@ del _EmptyClass # fmt: on +def unwrap_metafunc_parametrize_and_possibly_prune_dependency_tree(metafunc): + metafunc.parametrize = metafunc._parametrize + del metafunc._parametrize + if metafunc.has_dynamic_parametrize: + # Direct parametrization may have shadowed some fixtures + # so make sure we update what the function really needs. + fixture_closure, arg2num_fixturedefs_used = FixtureManager.getfixtureclosure( + metafunc.definition, + metafunc.definition._fixtureinfo.initialnames, + metafunc._arg2fixturedefs, + ignore_args=_get_direct_parametrize_args(metafunc.definition) + ["request"], + ) + metafunc.fixturenames[:] = fixture_closure + metafunc.definition._fixtureinfo.name2num_fixturedefs_used.clear() + metafunc.definition._fixtureinfo.name2num_fixturedefs_used.update( + arg2num_fixturedefs_used + ) + del metafunc.has_dynamic_parametrize + + class PyCollector(PyobjMixin, nodes.Collector): def funcnamefilter(self, name: str) -> bool: return self._matches_prefix_or_glob_option("python_functions", name) @@ -484,8 +506,6 @@ class PyCollector(PyobjMixin, nodes.Collector): definition = FunctionDefinition.from_parent(self, name=name, callobj=funcobj) fixtureinfo = definition._fixtureinfo - # pytest_generate_tests impls call metafunc.parametrize() which fills - # metafunc._calls, the outcome of the hook. metafunc = Metafunc( definition=definition, fixtureinfo=fixtureinfo, @@ -494,19 +514,29 @@ class PyCollector(PyobjMixin, nodes.Collector): module=module, _ispytest=True, ) - methods = [] + methods = [unwrap_metafunc_parametrize_and_possibly_prune_dependency_tree] if hasattr(module, "pytest_generate_tests"): methods.append(module.pytest_generate_tests) if cls is not None and hasattr(cls, "pytest_generate_tests"): methods.append(cls().pytest_generate_tests) + metafunc.has_dynamic_parametrize = False + from functools import wraps + + @wraps(metafunc.parametrize) + def set_has_dynamic_parametrize(*args, **kwargs): + metafunc.has_dynamic_parametrize = True + metafunc._parametrize(*args, **kwargs) + + metafunc._parametrize = metafunc.parametrize + metafunc.parametrize = set_has_dynamic_parametrize + + # pytest_generate_tests impls call metafunc.parametrize() which fills + # metafunc._calls, the outcome of the hook. self.ihook.pytest_generate_tests.call_extra(methods, dict(metafunc=metafunc)) + if not metafunc._calls: yield Function.from_parent(self, name=name, fixtureinfo=fixtureinfo) else: - # Direct parametrization may have shadowed some fixtures - # so make sure we update what the function really needs. - fixtureinfo.prune_dependency_tree() - for callspec in metafunc._calls: subname = f"{name}[{callspec.id}]" yield Function.from_parent( @@ -1360,7 +1390,7 @@ class Metafunc: if arg_values_types[argname] == "params": continue if name2pseudofixturedef is not None and argname in name2pseudofixturedef: - arg2fixturedefs[argname] = [name2pseudofixturedef[argname]] + fixturedef = name2pseudofixturedef[argname] else: fixturedef = FixtureDef( fixturemanager=self.definition.session._fixturemanager, @@ -1373,9 +1403,10 @@ class Metafunc: ids=None, is_pseudo=True, ) - arg2fixturedefs[argname] = [fixturedef] if name2pseudofixturedef is not None: name2pseudofixturedef[argname] = fixturedef + arg2fixturedefs[argname] = [fixturedef] + self.definition._fixtureinfo.name2num_fixturedefs_used[argname] = 1 # Create the new calls: if we are parametrize() multiple times (by applying the decorator # more than once) then we accumulate those calls generating the cartesian product @@ -1557,7 +1588,7 @@ def _find_parametrized_scope( if all_arguments_are_fixtures: fixturedefs = arg2fixturedefs or {} used_scopes = [ - fixturedef[0]._scope + fixturedef[0]._scope # Shouldn't be -1 ? for name, fixturedef in fixturedefs.items() if name in argnames ] diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 9828df6fc..fc896bf75 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -2,6 +2,7 @@ import os import sys import textwrap from pathlib import Path +from unittest.mock import Mock import pytest from _pytest import fixtures @@ -2681,16 +2682,19 @@ class TestFixtureMarker: """ ) result = pytester.runpytest("-v") + # Order changed because fixture keys were sorted by their names in fixtures::get_fixture_keys + # beforehand so encap key came before flavor. This isn't problematic here as both fixtures + # are session-scoped but when this isn't the case, it might be problematic. result.stdout.fnmatch_lines( """ test_dynamic_parametrized_ordering.py::test[flavor1-vxlan] PASSED test_dynamic_parametrized_ordering.py::test2[flavor1-vxlan] PASSED - test_dynamic_parametrized_ordering.py::test[flavor2-vxlan] PASSED - test_dynamic_parametrized_ordering.py::test2[flavor2-vxlan] PASSED - test_dynamic_parametrized_ordering.py::test[flavor2-vlan] PASSED - test_dynamic_parametrized_ordering.py::test2[flavor2-vlan] PASSED test_dynamic_parametrized_ordering.py::test[flavor1-vlan] PASSED test_dynamic_parametrized_ordering.py::test2[flavor1-vlan] PASSED + test_dynamic_parametrized_ordering.py::test[flavor2-vlan] PASSED + test_dynamic_parametrized_ordering.py::test2[flavor2-vlan] PASSED + test_dynamic_parametrized_ordering.py::test[flavor2-vxlan] PASSED + test_dynamic_parametrized_ordering.py::test2[flavor2-vxlan] PASSED """ ) @@ -4475,39 +4479,39 @@ def test_yield_fixture_with_no_value(pytester: Pytester) -> None: assert result.ret == ExitCode.TESTS_FAILED -def test_teardown_high_scope_fixture_at_last_dependent_item_simple( +def test_early_teardown_simple( pytester: Pytester, ) -> None: pytester.makepyfile( """ import pytest - @pytest.fixture(scope='module', params=[None]) + @pytest.fixture(scope='module') def fixture(): - yield - print("Tearing down fixture!") + pass def test_0(fixture): pass def test_1(fixture): - print("Running test_1!") + pass def test_2(): - print("Running test_2!") + pass """ ) - result = pytester.runpytest("-s") - assert result.ret == 0 + result = pytester.runpytest("--setup-show") result.stdout.fnmatch_lines( [ - "*Running test_1!*", - "*Tearing down fixture!*", - "*Running test_2!*", + "*SETUP M fixture*", + "*test_0*", + "*test_1*", + "*TEARDOWN M fixture*", + "*test_2*", ] ) -def test_teardown_high_scope_fixture_at_last_dependent_item_simple_2( +def test_early_teardown_simple_2( pytester: Pytester, ) -> None: pytester.makepyfile( @@ -4515,37 +4519,75 @@ def test_teardown_high_scope_fixture_at_last_dependent_item_simple_2( import pytest @pytest.fixture(scope='module', params=[None]) def fixture1(): - yield - print("Tearing down fixture!") + pass @pytest.fixture(scope='module', params=[None]) def fixture2(): - yield - print("Tearing down fixture!") + pass def test_0(fixture1): pass def test_1(fixture1, fixture2): - print("Running test_1!") + pass def test_2(): - print("Running test_2!") + pass """ ) - result = pytester.runpytest("-s") + result = pytester.runpytest("--setup-show") assert result.ret == 0 result.stdout.fnmatch_lines( [ - "*Running test_1!*", - "*Tearing down fixture!*", - "*Tearing down fixture!*", - "*Running test_2!*", + "*SETUP M fixture1*", + "*test_0*", + "*SETUP M fixture2*", + "*test_1*", + "*TEARDOWN M fixture2*", + "*TEARDOWN M fixture1*", + "*test_2*", ] ) -def test_teardown_high_scope_fixture_at_last_dependent_item_complex( +def test_early_teardown_simple_3(pytester: Pytester): + pytester.makepyfile( + """ + import pytest + @pytest.fixture(scope='module') + def fixture1(): + pass + + @pytest.fixture(scope='module') + def fixture2(): + pass + + def test_0(fixture1, fixture2): + pass + + def test_1(fixture1): + pass + + def test_2(fixture1, fixture2): + pass + """ + ) + result = pytester.runpytest("--setup-show") + result.stdout.fnmatch_lines( + [ + "*SETUP M fixture1*", + "*SETUP M fixture2*", + "*test_0*", + "*test_2*", + "*TEARDOWN M fixture2*", + "*test_1*", + "*TEARDOWN M fixture1*", + ], + consecutive=True, + ) + + +def test_early_teardown_complex( pytester: Pytester, ) -> None: pytester.makepyfile( @@ -4634,6 +4676,141 @@ def test_teardown_high_scope_fixture_at_last_dependent_item_complex( ) +def test_fixtureclosure_contains_shadowed_fixtures(pytester: Pytester): + pytester.makeconftest( + """ + import pytest + + @pytest.fixture + def fixt0(): + pass + + @pytest.fixture + def fixt1(): + pass + + @pytest.fixture + def fixt2(fixt0, fixt1): + pass + """ + ) + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def fixt2(fixt2): + pass + + @pytest.fixture + def fixt3(): + pass + + def test(fixt2, fixt3): + pass + """ + ) + result = pytester.runpytest("--setup-show") + result.stdout.fnmatch_lines( + [ + "*SETUP F fixt0*", + "*SETUP F fixt1*", + "*SETUP F fixt2 (fixtures used: fixt0, fixt1)*", + "*SETUP F fixt2 (fixtures used: fixt2)*", + "*SETUP F fixt3*", + "*test (fixtures used: fixt0, fixt1, fixt2, fixt3)*", + ] + ) + + +def test_early_teardown_fixture_both_overriden_and_being_used(pytester: Pytester): + pytester.makeconftest( + """ + import pytest + + @pytest.fixture(scope='session') + def shadowed(): + pass + + @pytest.fixture + def intermediary(shadowed): + pass + """ + ) + pytester.makepyfile( + test_a=""" + import pytest + + def test_0(shadowed): + pass + """, + test_b=""" + import pytest + + @pytest.fixture(scope='session') + def shadowed(): + pass + + def test_1(): + pass + + def test_2(shadowed, intermediary): + pass + """, + ) + result = pytester.runpytest("--setup-show") + result.stdout.fnmatch_lines( + [ + "*SETUP S shadowed*", + "*test_0*", + "*TEARDOWN S shadowed*", + "*test_1*", + "*SETUP S shadowed*", + "*SETUP F intermediary*", + "*test_2*", + "*TEARDOWN F intermediary*", + "*TEARDOWN S shadowed*", + ] + ) + + +def test_early_teardown_homonym_session_scoped_fixtures(pytester: Pytester): + """Session-scoped fixtures, as only argname and baseid are active in their + corresponding arg keys.""" + pytester.makepyfile( + test_a=""" + import pytest + @pytest.fixture(scope='session') + def fixture(): + yield 0 + + def test_0(fixture): + assert fixture == 0 + """, + test_b=""" + import pytest + @pytest.fixture(scope='session') + def fixture(): + yield 1 + + def test_1(fixture): + assert fixture == 1 + """, + ) + result = pytester.runpytest("--setup-show") + assert result.ret == 0 + result.stdout.fnmatch_lines( + [ + "*SETUP S fixture*", + "*test_0*", + "*TEARDOWN S fixture*", + "*SETUP S fixture", + "*test_1*", + "*TEARDOWN S fixture*", + ] + ) + + def test_reorder_with_nonparametrized_fixtures(pytester: Pytester): path = pytester.makepyfile( """ @@ -4696,6 +4873,326 @@ def test_reorder_with_both_parametrized_and_nonparametrized_fixtures( result.stdout.fnmatch_lines([f"*test_{i}*" for i in [0, 2, 1]]) +def test_early_teardown_parametrized_homonym_parent_fixture(pytester: Pytester): + pytester.makeconftest( + """ + import pytest + + @pytest.fixture(params=[0, 1], scope='session') + def fixture(request): + pass + """ + ) + pytester.makepyfile( + test_a=""" + import pytest + + @pytest.fixture(scope='session') + def fixture(fixture): + pass + + def test_0(fixture): + pass + + def test_1(): + pass + + def test_2(fixture): + pass + """, + test_b=""" + def test_3(fixture): + pass + + def test_4(): + pass + """, + test_c=""" + import pytest + + @pytest.fixture(scope='session') + def fixture(): + pass + + def test_5(fixture): + pass + """, + ) + result = pytester.runpytest("--setup-show") + result.stdout.re_match_lines( + [ + r"\s*SETUP S fixture\[0\].*", + r"\s*SETUP S fixture.*", + r".*test_0\[0\].*", + r".*test_2\[0\].*", + r"\s*TEARDOWN S fixture.*", + r".*test_3\[0\].*", + r"\s*TEARDOWN S fixture\[0\].*", + r"\s*SETUP S fixture\[1\].*", + r"\s*SETUP S fixture.*", + r".*test_0\[1\].*", + r".*test_2\[1\].*", + r"\s*TEARDOWN S fixture.*", + r".*test_3\[1\].*", + r"\s*TEARDOWN S fixture\[1\].*", + r".*test_1.*", + r".*test_4.*", + r"\s*SETUP S fixture.*", + r".*test_5.*", + r"\s*TEARDOWN S fixture.*", + ] + ) + + +def test_early_teardown_parametrized_homonym_parent_and_child_fixture( + pytester: Pytester, +): + pytester.makeconftest( + """ + import pytest + + @pytest.fixture(params=[0, 1], scope='session') + def fixture(request): + pass + """ + ) + pytester.makepyfile( + test_a=""" + import pytest + + @pytest.fixture(params=[2, 3], scope='session') + def fixture(request, fixture): + pass + + def test_0(fixture): + pass + + def test_1(): + pass + + def test_2(fixture): + pass + """, + test_b=""" + def test_3(fixture): + pass + + def test_4(): + pass + + def test_5(fixture): + pass + """, + ) + result = pytester.runpytest("--setup-show") + result.stdout.re_match_lines( + [ + r"\s*SETUP S fixture.*", + r"\s*SETUP S fixture \(fixtures used: fixture\)\[2\].*", + r".*test_0.*", + r".*test_2.*", + r"\s*TEARDOWN S fixture\[2\].*", + r"\s*TEARDOWN S fixture.*", + r"\s*SETUP S fixture.*", + r"\s*SETUP S fixture \(fixtures used: fixture\)\[3\].*", + r".*test_0.*", + r".*test_2.*", + r"\s*TEARDOWN S fixture\[3\].*", + r"\s*TEARDOWN S fixture.*", + r".*test_1.*", + r"\s*SETUP S fixture\[0\].*", + r".*test_3.*", + r".*test_5.*", + r"\s*TEARDOWN S fixture\[0\].*", + r"\s*SETUP S fixture\[1\].*", + r".*test_3.*", + r".*test_5.*", + r"\s*TEARDOWN S fixture\[1\].*", + r".*test_4.*", + ] + ) + + +def test_early_teardown_parametrized_overriden_and_overrider_fixture( + pytester: Pytester, +): + pytester.makeconftest( + """ + import pytest + + @pytest.fixture(params=[0, 1], scope='session') + def fixture(request): + pass + """ + ) + pytester.makepyfile( + test_a=""" + def test_0(fixture): + pass + + def test_1(): + pass + + def test_2(fixture): + pass + + def test_3(): + pass + """, + test_b=""" + import pytest + + @pytest.fixture(params=[2, 3], scope='session') + def fixture(request): + pass + + def test_4(fixture): + pass + + def test_5(): + pass + + def test_6(fixture): + pass + """, + ) + result = pytester.runpytest("--setup-show") + result.stdout.re_match_lines( + [ + r"\s*SETUP S fixture\[0\].*", + r".*test_0.*", + r".*test_2.*", + r"\s*TEARDOWN S fixture\[0\].*", + r"\s*SETUP S fixture\[1\].*", + r".*test_0.*", + r".*test_2.*", + r"\s*TEARDOWN S fixture\[1\].*", + r".*test_3.*", + r"\s*SETUP S fixture\[2\].*", + r".*test_4.*", + r".*test_6.*", + r"\s*TEARDOWN S fixture\[2\].*", + r"\s*SETUP S fixture\[3\].*", + r".*test_4.*", + r".*test_6.*", + r"\s*TEARDOWN S fixture\[3\].*", + r".*test_5.*", + ] + ) + + +def test_early_teardown_indirectly_parametrized_fixture(pytester: Pytester): + pytester.makeconftest( + """ + import pytest + + @pytest.fixture(params=[0, 1], scope='session') + def fixture(request): + pass + """ + ) + pytester.makepyfile( + test_a=""" + import pytest + + @pytest.fixture(scope='session') + def fixture(request, fixture): + pass + + @pytest.mark.parametrize('fixture', [2, 3], indirect=True) + def test_0(fixture): + pass + + def test_1(): + pass + + def test_2(fixture): + pass + + def test_3(): + pass + + def test_4(fixture): + pass + """, + test_b=""" + def test_5(): + pass + + def test_6(fixture): + pass + """, + ) + result = pytester.runpytest("--setup-show") + result.stdout.re_match_lines( + [ + r"\s*SETUP S fixture.*", + r"\s*SETUP S fixture \(fixtures used: fixture\)\[2\].*", + r".*test_0\[2\].*", + r"\s*TEARDOWN S fixture\[2\].*", + # One might expect conftest::fixture not to tear down here, as test_0[3] will use it afterwards, + # but since conftest::fixture gets parameter although it's not parametrized on test_0, it tears down + # itself as soon as it sees the parameter, which has nothing to do with, has changed from 2 to 3. + # In the future we could change callspec param dict to have the fixture baseid in its key as well to + # satisfy this expectation. + r"\s*TEARDOWN S fixture.*", + r"\s*SETUP S fixture.*", + r"\s*SETUP S fixture \(fixtures used: fixture\)\[3\].*", + r".*test_0\[3\].*", + r"\s*TEARDOWN S fixture\[3\].*", + r"\s*TEARDOWN S fixture.*", + r".*test_1.*", + r"\s*SETUP S fixture\[0\].*", + r"\s*SETUP S fixture.*", + r".*test_2\[0\].*", + r".*test_4\[0\].*", + r"\s*TEARDOWN S fixture.*", + r".*test_6\[0\].*", + r"\s*TEARDOWN S fixture\[0\].*", + r"\s*SETUP S fixture\[1\].*", + r"\s*SETUP S fixture.*", + r".*test_2\[1\].*", + r".*test_4\[1\].*", + r"\s*TEARDOWN S fixture.*", + r".*test_6\[1\].*", + r"\s*TEARDOWN S fixture\[1\].*", + r".*test_3.*", + r".*test_5.*", + ] + ) + + +def test_item_determines_which_def_to_use_not_the_requester(pytester: Pytester): + pytester.makeconftest( + """ + import pytest + + @pytest.fixture + def fixture(): + yield 1 + + @pytest.fixture + def intermediary(fixture): + yield fixture + """ + ) + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def fixture(): + yield 2 + + def test_0(intermediary): + "If requester(intermediary here) was to determine which fixture to use, we should have had 1" + assert intermediary == 2 + """ + ) + result = pytester.runpytest() + result.ret == 0 + + def test_add_new_test_dependent_on_a_fixuture_and_use_nfplugin(pytester: Pytester): test_module_string = """ import pytest @@ -4765,9 +5262,6 @@ def test_last_dependent_test_on_a_fixture_is_in_last_failed_using_lfplugin( ) -@pytest.mark.xfail( - reason="We do not attempt to tear down early the fixture that is overridden and also is used" -) def test_early_teardown_of_overridden_and_being_used_fixture( pytester: Pytester, ) -> None: @@ -4777,8 +5271,8 @@ def test_early_teardown_of_overridden_and_being_used_fixture( @pytest.fixture(scope='module') def fixture0(): - yield None - print("Tearing down higher-level fixture0") + # This fixture is used by its overrider + pass """ ) pytester.makepyfile( @@ -4787,23 +5281,26 @@ def test_early_teardown_of_overridden_and_being_used_fixture( @pytest.fixture(scope='module') def fixture0(fixture0): - yield None - print("Tearing down lower-level fixture0") + pass def test_0(fixture0): pass def test_1(): - print("Both `fixture0`s should have been torn down") + pass """ ) - result = pytester.runpytest("-s") + result = pytester.runpytest("--setup-show") result.stdout.fnmatch_lines( [ - "*Tearing down lower-level fixture0*", - "*Tearing down higher-level fixture0*", - "*Both `fixture0`s should have been torn down*", - ] + "*SETUP M fixture0*", + "*SETUP M fixture0*", + "*test_0*", + "*TEARDOWN M fixture0*", + "*TEARDOWN M fixture0*", + "*test_1*", + ], + consecutive=True, ) @@ -5010,3 +5507,125 @@ def test_early_teardown_does_not_occur_for_pseudo_fixtures(pytester: Pytester) - import functools assert not any([isinstance(item.teardown, functools.partial) for item in items]) + + +def test_fixture_info_after_dynamic_parametrize(pytester: Pytester) -> None: + pytester.makeconftest( + """ + import pytest + + @pytest.fixture(scope='session', params=[0, 1]) + def fixture1(request): + pass + + @pytest.fixture(scope='session') + def fixture2(fixture1): + pass + + @pytest.fixture(scope='session', params=[2, 3]) + def fixture3(request, fixture2): + pass + """ + ) + pytester.makepyfile( + """ + import pytest + def pytest_generate_tests(metafunc): + metafunc.parametrize("fixture2", [4, 5], scope='session') + + @pytest.fixture(scope='session') + def fixture4(): + pass + + @pytest.fixture(scope='session') + def fixture2(fixture3, fixture4): + pass + + def test(fixture2): + pass + """ + ) + res = pytester.inline_run() + res.assertoutcome(passed=2) + + +def test_reordering_after_dynamic_parametrize(pytester: Pytester): + pytester.makepyfile( + """ + import pytest + + def pytest_generate_tests(metafunc): + if metafunc.definition.name == "test_0": + metafunc.parametrize("fixture2", [0]) + + @pytest.fixture(scope='module') + def fixture1(): + pass + + @pytest.fixture(scope='module') + def fixture2(fixture1): + pass + + def test_0(fixture2): + pass + + def test_1(): + pass + + def test_2(fixture1): + pass + """ + ) + result = pytester.runpytest("--collect-only") + result.stdout.fnmatch_lines( + [ + "*test_0*", + "*test_1*", + "*test_2*", + ], + consecutive=True, + ) + + +def test_dont_recompute_dependency_tree_if_no_dynamic_parametrize( + pytester: Pytester, monkeypatch: MonkeyPatch +): + pytester.makepyfile( + """ + import pytest + + def pytest_generate_tests(metafunc): + if metafunc.definition.name == "test_0": + metafunc.parametrize("fixture", [0]) + + @pytest.fixture(scope='module') + def fixture(): + pass + + def test_0(fixture): + pass + + def test_1(): + pass + + @pytest.mark.parametrize("fixture", [0]) + def test_2(fixture): + pass + + @pytest.mark.parametrize("fixture", [0], indirect=True) + def test_3(fixture): + pass + """ + ) + from _pytest.fixtures import FixtureManager + + with monkeypatch.context() as m: + mocked_function = Mock(wraps=FixtureManager.getfixtureclosure) + m.setattr(FixtureManager, "getfixtureclosure", mocked_function) + pytester.inline_run() + assert len(mocked_function.call_args_list) == 5 + assert mocked_function.call_args_list[0].args[0].nodeid.endswith("test_0") + assert mocked_function.call_args_list[1].args[0].nodeid.endswith("test_0") + assert mocked_function.call_args_list[2].args[0].nodeid.endswith("test_1") + assert mocked_function.call_args_list[3].args[0].nodeid.endswith("test_2") + assert mocked_function.call_args_list[4].args[0].nodeid.endswith("test_3") diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index a1a39b87e..61b5e7c1c 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -35,6 +35,7 @@ class TestMetafunc: # initialization. class FuncFixtureInfoMock: name2fixturedefs = {} + name2num_fixturedefs_used = {} def __init__(self, names): self.names_closure = names @@ -55,6 +56,7 @@ class TestMetafunc: names = getfuncargnames(func) fixtureinfo: Any = FuncFixtureInfoMock(names) definition: Any = DefinitionMock._create(obj=func, _nodeid="mock::nodeid") + definition._fixtureinfo = fixtureinfo definition.session = SessionMock(FixtureManagerMock({})) return python.Metafunc(definition, fixtureinfo, config, _ispytest=True)