Skip to content

Commit b9aebdb

Browse files
sadra-barikbinbluetech
authored andcommitted
Remove prune_dependency_tree and reuse getfixtureclosure logic
1 parent 448563c commit b9aebdb

File tree

3 files changed

+214
-59
lines changed

3 files changed

+214
-59
lines changed

src/_pytest/fixtures.py

Lines changed: 34 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -383,33 +383,6 @@ class FuncFixtureInfo:
383383
# sequence is ordered from furthest to closes to the function.
384384
name2fixturedefs: Dict[str, Sequence["FixtureDef[Any]"]]
385385

386-
def prune_dependency_tree(self) -> None:
387-
"""Recompute names_closure from initialnames and name2fixturedefs.
388-
389-
Can only reduce names_closure, which means that the new closure will
390-
always be a subset of the old one. The order is preserved.
391-
392-
This method is needed because direct parametrization may shadow some
393-
of the fixtures that were included in the originally built dependency
394-
tree. In this way the dependency tree can get pruned, and the closure
395-
of argnames may get reduced.
396-
"""
397-
closure: Set[str] = set()
398-
working_set = set(self.initialnames)
399-
while working_set:
400-
argname = working_set.pop()
401-
# Argname may be smth not included in the original names_closure,
402-
# in which case we ignore it. This currently happens with pseudo
403-
# FixtureDefs which wrap 'get_direct_param_fixture_func(request)'.
404-
# So they introduce the new dependency 'request' which might have
405-
# been missing in the original tree (closure).
406-
if argname not in closure and argname in self.names_closure:
407-
closure.add(argname)
408-
if argname in self.name2fixturedefs:
409-
working_set.update(self.name2fixturedefs[argname][-1].argnames)
410-
411-
self.names_closure[:] = sorted(closure, key=self.names_closure.index)
412-
413386

414387
class FixtureRequest:
415388
"""A request for a fixture from a test or fixture function.
@@ -1502,11 +1475,28 @@ def getfixtureinfo(
15021475
usefixtures = tuple(
15031476
arg for mark in node.iter_markers(name="usefixtures") for arg in mark.args
15041477
)
1505-
initialnames = usefixtures + argnames
1506-
initialnames, names_closure, arg2fixturedefs = self.getfixtureclosure(
1507-
initialnames, node, ignore_args=_get_direct_parametrize_args(node)
1478+
initialnames = cast(
1479+
Tuple[str],
1480+
tuple(
1481+
dict.fromkeys(
1482+
tuple(self._getautousenames(node.nodeid)) + usefixtures + argnames
1483+
)
1484+
),
1485+
)
1486+
1487+
arg2fixturedefs: Dict[str, Sequence[FixtureDef[Any]]] = {}
1488+
names_closure = self.getfixtureclosure(
1489+
node,
1490+
initialnames,
1491+
arg2fixturedefs,
1492+
ignore_args=_get_direct_parametrize_args(node),
1493+
)
1494+
return FuncFixtureInfo(
1495+
argnames,
1496+
initialnames,
1497+
names_closure,
1498+
arg2fixturedefs,
15081499
)
1509-
return FuncFixtureInfo(argnames, initialnames, names_closure, arg2fixturedefs)
15101500

15111501
def pytest_plugin_registered(self, plugin: _PluggyPlugin) -> None:
15121502
nodeid = None
@@ -1539,45 +1529,38 @@ def _getautousenames(self, nodeid: str) -> Iterator[str]:
15391529

15401530
def getfixtureclosure(
15411531
self,
1542-
fixturenames: Tuple[str, ...],
15431532
parentnode: nodes.Node,
1533+
initialnames: Tuple[str],
1534+
arg2fixturedefs: Dict[str, Sequence[FixtureDef[Any]]],
15441535
ignore_args: Sequence[str] = (),
1545-
) -> Tuple[Tuple[str, ...], List[str], Dict[str, Sequence[FixtureDef[Any]]]]:
1536+
) -> List[str]:
15461537
# Collect the closure of all fixtures, starting with the given
1547-
# fixturenames as the initial set. As we have to visit all
1548-
# factory definitions anyway, we also return an arg2fixturedefs
1538+
# initialnames as the initial set. As we have to visit all
1539+
# factory definitions anyway, we also populate arg2fixturedefs
15491540
# mapping so that the caller can reuse it and does not have
15501541
# to re-discover fixturedefs again for each fixturename
15511542
# (discovering matching fixtures for a given name/node is expensive).
15521543

1553-
parentid = parentnode.nodeid
1554-
fixturenames_closure = list(self._getautousenames(parentid))
1544+
fixturenames_closure = list(initialnames)
15551545

15561546
def merge(otherlist: Iterable[str]) -> None:
15571547
for arg in otherlist:
15581548
if arg not in fixturenames_closure:
15591549
fixturenames_closure.append(arg)
15601550

1561-
merge(fixturenames)
1562-
1563-
# At this point, fixturenames_closure contains what we call "initialnames",
1564-
# which is a set of fixturenames the function immediately requests. We
1565-
# need to return it as well, so save this.
1566-
initialnames = tuple(fixturenames_closure)
1567-
1568-
arg2fixturedefs: Dict[str, Sequence[FixtureDef[Any]]] = {}
15691551
lastlen = -1
1552+
parentid = parentnode.nodeid
15701553
while lastlen != len(fixturenames_closure):
15711554
lastlen = len(fixturenames_closure)
15721555
for argname in fixturenames_closure:
15731556
if argname in ignore_args:
15741557
continue
1558+
if argname not in arg2fixturedefs:
1559+
fixturedefs = self.getfixturedefs(argname, parentid)
1560+
if fixturedefs:
1561+
arg2fixturedefs[argname] = fixturedefs
15751562
if argname in arg2fixturedefs:
1576-
continue
1577-
fixturedefs = self.getfixturedefs(argname, parentid)
1578-
if fixturedefs:
1579-
arg2fixturedefs[argname] = fixturedefs
1580-
merge(fixturedefs[-1].argnames)
1563+
merge(arg2fixturedefs[argname][-1].argnames)
15811564

15821565
def sort_by_scope(arg_name: str) -> Scope:
15831566
try:
@@ -1588,7 +1571,7 @@ def sort_by_scope(arg_name: str) -> Scope:
15881571
return fixturedefs[-1]._scope
15891572

15901573
fixturenames_closure.sort(key=sort_by_scope, reverse=True)
1591-
return initialnames, fixturenames_closure, arg2fixturedefs
1574+
return fixturenames_closure
15921575

15931576
def pytest_generate_tests(self, metafunc: "Metafunc") -> None:
15941577
"""Generate new tests based on parametrized fixtures used by the given metafunc"""

src/_pytest/python.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from collections import Counter
1212
from collections import defaultdict
1313
from functools import partial
14+
from functools import wraps
1415
from pathlib import Path
1516
from typing import Any
1617
from typing import Callable
@@ -59,6 +60,7 @@
5960
from _pytest.deprecated import check_ispytest
6061
from _pytest.deprecated import INSTANCE_COLLECTOR
6162
from _pytest.deprecated import NOSE_SUPPORT_METHOD
63+
from _pytest.fixtures import _get_direct_parametrize_args
6264
from _pytest.fixtures import FuncFixtureInfo
6365
from _pytest.main import Session
6466
from _pytest.mark import MARK_GEN
@@ -380,6 +382,23 @@ class _EmptyClass: pass # noqa: E701
380382
# fmt: on
381383

382384

385+
def unwrap_metafunc_parametrize_and_possibly_prune_dependency_tree(metafunc):
386+
metafunc.parametrize = metafunc._parametrize
387+
del metafunc._parametrize
388+
if metafunc.has_dynamic_parametrize:
389+
# Dynamic direct parametrization may have shadowed some fixtures
390+
# so make sure we update what the function really needs.
391+
definition = metafunc.definition
392+
fixture_closure = definition.parent.session._fixturemanager.getfixtureclosure(
393+
definition,
394+
definition._fixtureinfo.initialnames,
395+
definition._fixtureinfo.name2fixturedefs,
396+
ignore_args=_get_direct_parametrize_args(definition) + ["request"],
397+
)
398+
definition._fixtureinfo.names_closure[:] = fixture_closure
399+
del metafunc.has_dynamic_parametrize
400+
401+
383402
class PyCollector(PyobjMixin, nodes.Collector):
384403
def funcnamefilter(self, name: str) -> bool:
385404
return self._matches_prefix_or_glob_option("python_functions", name)
@@ -476,8 +495,6 @@ def _genfunctions(self, name: str, funcobj) -> Iterator["Function"]:
476495
definition = FunctionDefinition.from_parent(self, name=name, callobj=funcobj)
477496
fixtureinfo = definition._fixtureinfo
478497

479-
# pytest_generate_tests impls call metafunc.parametrize() which fills
480-
# metafunc._calls, the outcome of the hook.
481498
metafunc = Metafunc(
482499
definition=definition,
483500
fixtureinfo=fixtureinfo,
@@ -486,11 +503,24 @@ def _genfunctions(self, name: str, funcobj) -> Iterator["Function"]:
486503
module=module,
487504
_ispytest=True,
488505
)
489-
methods = []
506+
methods = [unwrap_metafunc_parametrize_and_possibly_prune_dependency_tree]
490507
if hasattr(module, "pytest_generate_tests"):
491508
methods.append(module.pytest_generate_tests)
492509
if cls is not None and hasattr(cls, "pytest_generate_tests"):
493510
methods.append(cls().pytest_generate_tests)
511+
512+
setattr(metafunc, "has_dynamic_parametrize", False)
513+
514+
@wraps(metafunc.parametrize)
515+
def set_has_dynamic_parametrize(*args, **kwargs):
516+
setattr(metafunc, "has_dynamic_parametrize", True)
517+
metafunc._parametrize(*args, **kwargs) # type: ignore[attr-defined]
518+
519+
setattr(metafunc, "_parametrize", metafunc.parametrize)
520+
setattr(metafunc, "parametrize", set_has_dynamic_parametrize)
521+
522+
# pytest_generate_tests impls call metafunc.parametrize() which fills
523+
# metafunc._calls, the outcome of the hook.
494524
self.ihook.pytest_generate_tests.call_extra(methods, dict(metafunc=metafunc))
495525

496526
if not metafunc._calls:
@@ -500,11 +530,6 @@ def _genfunctions(self, name: str, funcobj) -> Iterator["Function"]:
500530
fm = self.session._fixturemanager
501531
fixtures.add_funcarg_pseudo_fixture_def(self, metafunc, fm)
502532

503-
# Add_funcarg_pseudo_fixture_def may have shadowed some fixtures
504-
# with direct parametrization, so make sure we update what the
505-
# function really needs.
506-
fixtureinfo.prune_dependency_tree()
507-
508533
for callspec in metafunc._calls:
509534
subname = f"{name}[{callspec.id}]"
510535
yield Function.from_parent(

testing/python/fixtures.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4536,3 +4536,150 @@ def test_fixt(custom):
45364536
result.assert_outcomes(errors=1)
45374537
result.stdout.fnmatch_lines([expected])
45384538
assert result.ret == ExitCode.TESTS_FAILED
4539+
4540+
4541+
@pytest.mark.xfail(
4542+
reason="arg2fixturedefs should get updated on dynamic parametrize. This gets solved by PR#11220"
4543+
)
4544+
def test_fixture_info_after_dynamic_parametrize(pytester: Pytester) -> None:
4545+
pytester.makeconftest(
4546+
"""
4547+
import pytest
4548+
4549+
@pytest.fixture(scope='session', params=[0, 1])
4550+
def fixture1(request):
4551+
pass
4552+
4553+
@pytest.fixture(scope='session')
4554+
def fixture2(fixture1):
4555+
pass
4556+
4557+
@pytest.fixture(scope='session', params=[2, 3])
4558+
def fixture3(request, fixture2):
4559+
pass
4560+
"""
4561+
)
4562+
pytester.makepyfile(
4563+
"""
4564+
import pytest
4565+
def pytest_generate_tests(metafunc):
4566+
metafunc.parametrize("fixture2", [4, 5], scope='session')
4567+
4568+
@pytest.fixture(scope='session')
4569+
def fixture4():
4570+
pass
4571+
4572+
@pytest.fixture(scope='session')
4573+
def fixture2(fixture3, fixture4):
4574+
pass
4575+
4576+
def test(fixture2):
4577+
assert fixture2 in (4, 5)
4578+
"""
4579+
)
4580+
res = pytester.inline_run("-s")
4581+
res.assertoutcome(passed=2)
4582+
4583+
4584+
def test_reordering_after_dynamic_parametrize(pytester: Pytester):
4585+
pytester.makepyfile(
4586+
"""
4587+
import pytest
4588+
4589+
def pytest_generate_tests(metafunc):
4590+
if metafunc.definition.name == "test_0":
4591+
metafunc.parametrize("fixture2", [0])
4592+
4593+
@pytest.fixture(scope='module')
4594+
def fixture1():
4595+
pass
4596+
4597+
@pytest.fixture(scope='module')
4598+
def fixture2(fixture1):
4599+
pass
4600+
4601+
def test_0(fixture2):
4602+
pass
4603+
4604+
def test_1():
4605+
pass
4606+
4607+
def test_2(fixture1):
4608+
pass
4609+
"""
4610+
)
4611+
result = pytester.runpytest("--collect-only")
4612+
result.stdout.fnmatch_lines(
4613+
[
4614+
"*test_0*",
4615+
"*test_1*",
4616+
"*test_2*",
4617+
],
4618+
consecutive=True,
4619+
)
4620+
4621+
4622+
def test_dont_recompute_dependency_tree_if_no_dynamic_parametrize(pytester: Pytester):
4623+
pytester.makeconftest(
4624+
"""
4625+
import pytest
4626+
from _pytest.config import hookimpl
4627+
from unittest.mock import Mock
4628+
4629+
original_method = None
4630+
4631+
@hookimpl(trylast=True)
4632+
def pytest_sessionstart(session):
4633+
global original_method
4634+
original_method = session._fixturemanager.getfixtureclosure
4635+
session._fixturemanager.getfixtureclosure = Mock(wraps=original_method)
4636+
4637+
@hookimpl(tryfirst=True)
4638+
def pytest_sessionfinish(session, exitstatus):
4639+
global original_method
4640+
session._fixturemanager.getfixtureclosure = original_method
4641+
"""
4642+
)
4643+
pytester.makepyfile(
4644+
"""
4645+
import pytest
4646+
4647+
def pytest_generate_tests(metafunc):
4648+
if metafunc.definition.name == "test_0":
4649+
metafunc.parametrize("fixture", [0])
4650+
4651+
@pytest.fixture(scope='module')
4652+
def fixture():
4653+
pass
4654+
4655+
def test_0(fixture):
4656+
pass
4657+
4658+
def test_1():
4659+
pass
4660+
4661+
@pytest.mark.parametrize("fixture", [0])
4662+
def test_2(fixture):
4663+
pass
4664+
4665+
@pytest.mark.parametrize("fixture", [0], indirect=True)
4666+
def test_3(fixture):
4667+
pass
4668+
4669+
@pytest.fixture
4670+
def fm(request):
4671+
yield request._fixturemanager
4672+
4673+
def test(fm):
4674+
method = fm.getfixtureclosure
4675+
assert len(method.call_args_list) == 6
4676+
assert method.call_args_list[0].args[0].nodeid.endswith("test_0")
4677+
assert method.call_args_list[1].args[0].nodeid.endswith("test_0")
4678+
assert method.call_args_list[2].args[0].nodeid.endswith("test_1")
4679+
assert method.call_args_list[3].args[0].nodeid.endswith("test_2")
4680+
assert method.call_args_list[4].args[0].nodeid.endswith("test_3")
4681+
assert method.call_args_list[5].args[0].nodeid.endswith("test")
4682+
"""
4683+
)
4684+
reprec = pytester.inline_run()
4685+
reprec.assertoutcome(passed=5)

0 commit comments

Comments
 (0)