Skip to content

Commit b0aaca4

Browse files
Do the improvement
1 parent 485c555 commit b0aaca4

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
@@ -390,33 +390,6 @@ class FuncFixtureInfo:
390390
# sequence is ordered from furthest to closes to the function.
391391
name2fixturedefs: Dict[str, Sequence["FixtureDef[Any]"]]
392392

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

421394
class FixtureRequest:
422395
"""A request for a fixture from a test or fixture function.
@@ -1509,11 +1482,28 @@ def getfixtureinfo(
15091482
usefixtures = tuple(
15101483
arg for mark in node.iter_markers(name="usefixtures") for arg in mark.args
15111484
)
1512-
initialnames = usefixtures + argnames
1513-
initialnames, names_closure, arg2fixturedefs = self.getfixtureclosure(
1514-
initialnames, node, ignore_args=_get_direct_parametrize_args(node)
1485+
initialnames = cast(
1486+
Tuple[str],
1487+
tuple(
1488+
dict.fromkeys(
1489+
tuple(self._getautousenames(node.nodeid)) + usefixtures + argnames
1490+
)
1491+
),
1492+
)
1493+
1494+
arg2fixturedefs: Dict[str, Sequence[FixtureDef[Any]]] = {}
1495+
names_closure = self.getfixtureclosure(
1496+
node,
1497+
initialnames,
1498+
arg2fixturedefs,
1499+
ignore_args=_get_direct_parametrize_args(node),
1500+
)
1501+
return FuncFixtureInfo(
1502+
argnames,
1503+
initialnames,
1504+
names_closure,
1505+
arg2fixturedefs,
15151506
)
1516-
return FuncFixtureInfo(argnames, initialnames, names_closure, arg2fixturedefs)
15171507

15181508
def pytest_plugin_registered(self, plugin: _PluggyPlugin) -> None:
15191509
nodeid = None
@@ -1546,45 +1536,38 @@ def _getautousenames(self, nodeid: str) -> Iterator[str]:
15461536

15471537
def getfixtureclosure(
15481538
self,
1549-
fixturenames: Tuple[str, ...],
15501539
parentnode: nodes.Node,
1540+
initialnames: Tuple[str],
1541+
arg2fixturedefs: Dict[str, Sequence[FixtureDef[Any]]],
15511542
ignore_args: Sequence[str] = (),
1552-
) -> Tuple[Tuple[str, ...], List[str], Dict[str, Sequence[FixtureDef[Any]]]]:
1543+
) -> List[str]:
15531544
# Collect the closure of all fixtures, starting with the given
1554-
# fixturenames as the initial set. As we have to visit all
1555-
# factory definitions anyway, we also return an arg2fixturedefs
1545+
# initialnames as the initial set. As we have to visit all
1546+
# factory definitions anyway, we also populate arg2fixturedefs
15561547
# mapping so that the caller can reuse it and does not have
15571548
# to re-discover fixturedefs again for each fixturename
15581549
# (discovering matching fixtures for a given name/node is expensive).
15591550

1560-
parentid = parentnode.nodeid
1561-
fixturenames_closure = list(self._getautousenames(parentid))
1551+
fixturenames_closure = list(initialnames)
15621552

15631553
def merge(otherlist: Iterable[str]) -> None:
15641554
for arg in otherlist:
15651555
if arg not in fixturenames_closure:
15661556
fixturenames_closure.append(arg)
15671557

1568-
merge(fixturenames)
1569-
1570-
# At this point, fixturenames_closure contains what we call "initialnames",
1571-
# which is a set of fixturenames the function immediately requests. We
1572-
# need to return it as well, so save this.
1573-
initialnames = tuple(fixturenames_closure)
1574-
1575-
arg2fixturedefs: Dict[str, Sequence[FixtureDef[Any]]] = {}
15761558
lastlen = -1
1559+
parentid = parentnode.nodeid
15771560
while lastlen != len(fixturenames_closure):
15781561
lastlen = len(fixturenames_closure)
15791562
for argname in fixturenames_closure:
15801563
if argname in ignore_args:
15811564
continue
1565+
if argname not in arg2fixturedefs:
1566+
fixturedefs = self.getfixturedefs(argname, parentid)
1567+
if fixturedefs:
1568+
arg2fixturedefs[argname] = fixturedefs
15821569
if argname in arg2fixturedefs:
1583-
continue
1584-
fixturedefs = self.getfixturedefs(argname, parentid)
1585-
if fixturedefs:
1586-
arg2fixturedefs[argname] = fixturedefs
1587-
merge(fixturedefs[-1].argnames)
1570+
merge(arg2fixturedefs[argname][-1].argnames)
15881571

15891572
def sort_by_scope(arg_name: str) -> Scope:
15901573
try:
@@ -1595,7 +1578,7 @@ def sort_by_scope(arg_name: str) -> Scope:
15951578
return fixturedefs[-1]._scope
15961579

15971580
fixturenames_closure.sort(key=sort_by_scope, reverse=True)
1598-
return initialnames, fixturenames_closure, arg2fixturedefs
1581+
return fixturenames_closure
15991582

16001583
def pytest_generate_tests(self, metafunc: "Metafunc") -> None:
16011584
"""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
@@ -377,6 +379,23 @@ class _EmptyClass: pass # noqa: E701
377379
# fmt: on
378380

379381

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

476-
# pytest_generate_tests impls call metafunc.parametrize() which fills
477-
# metafunc._calls, the outcome of the hook.
478495
metafunc = Metafunc(
479496
definition=definition,
480497
fixtureinfo=fixtureinfo,
@@ -483,11 +500,24 @@ def _genfunctions(self, name: str, funcobj) -> Iterator["Function"]:
483500
module=module,
484501
_ispytest=True,
485502
)
486-
methods = []
503+
methods = [unwrap_metafunc_parametrize_and_possibly_prune_dependency_tree]
487504
if hasattr(module, "pytest_generate_tests"):
488505
methods.append(module.pytest_generate_tests)
489506
if cls is not None and hasattr(cls, "pytest_generate_tests"):
490507
methods.append(cls().pytest_generate_tests)
508+
509+
setattr(metafunc, "has_dynamic_parametrize", False)
510+
511+
@wraps(metafunc.parametrize)
512+
def set_has_dynamic_parametrize(*args, **kwargs):
513+
setattr(metafunc, "has_dynamic_parametrize", True)
514+
metafunc._parametrize(*args, **kwargs) # type: ignore[attr-defined]
515+
516+
setattr(metafunc, "_parametrize", metafunc.parametrize)
517+
setattr(metafunc, "parametrize", set_has_dynamic_parametrize)
518+
519+
# pytest_generate_tests impls call metafunc.parametrize() which fills
520+
# metafunc._calls, the outcome of the hook.
491521
self.ihook.pytest_generate_tests.call_extra(methods, dict(metafunc=metafunc))
492522

493523
if not metafunc._calls:
@@ -497,11 +527,6 @@ def _genfunctions(self, name: str, funcobj) -> Iterator["Function"]:
497527
fm = self.session._fixturemanager
498528
fixtures.add_funcarg_pseudo_fixture_def(self, metafunc, fm)
499529

500-
# Add_funcarg_pseudo_fixture_def may have shadowed some fixtures
501-
# with direct parametrization, so make sure we update what the
502-
# function really needs.
503-
fixtureinfo.prune_dependency_tree()
504-
505530
for callspec in metafunc._calls:
506531
subname = f"{name}[{callspec.id}]"
507532
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)