Skip to content

Commit aa5d09d

Browse files
Do the improvement
1 parent 556e075 commit aa5d09d

File tree

3 files changed

+214
-60
lines changed

3 files changed

+214
-60
lines changed

src/_pytest/fixtures.py

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

315-
def prune_dependency_tree(self) -> None:
316-
"""Recompute names_closure from initialnames and name2fixturedefs.
317-
318-
Can only reduce names_closure, which means that the new closure will
319-
always be a subset of the old one. The order is preserved.
320-
321-
This method is needed because direct parametrization may shadow some
322-
of the fixtures that were included in the originally built dependency
323-
tree. In this way the dependency tree can get pruned, and the closure
324-
of argnames may get reduced.
325-
"""
326-
closure: Set[str] = set()
327-
working_set = set(self.initialnames)
328-
while working_set:
329-
argname = working_set.pop()
330-
# Argname may be smth not included in the original names_closure,
331-
# in which case we ignore it. This currently happens with pseudo
332-
# FixtureDefs which wrap 'get_direct_param_fixture_func(request)'.
333-
# So they introduce the new dependency 'request' which might have
334-
# been missing in the original tree (closure).
335-
if argname not in closure and argname in self.names_closure:
336-
closure.add(argname)
337-
if argname in self.name2fixturedefs:
338-
working_set.update(self.name2fixturedefs[argname][-1].argnames)
339-
340-
self.names_closure[:] = sorted(closure, key=self.names_closure.index)
341-
342315

343316
class FixtureRequest:
344317
"""A request for a fixture from a test or fixture function.
@@ -1431,11 +1404,28 @@ def getfixtureinfo(
14311404
usefixtures = tuple(
14321405
arg for mark in node.iter_markers(name="usefixtures") for arg in mark.args
14331406
)
1434-
initialnames = usefixtures + argnames
1435-
initialnames, names_closure, arg2fixturedefs = self.getfixtureclosure(
1436-
initialnames, node, ignore_args=_get_direct_parametrize_args(node)
1407+
initialnames = cast(
1408+
Tuple[str],
1409+
tuple(
1410+
dict.fromkeys(
1411+
tuple(self._getautousenames(node.nodeid)) + usefixtures + argnames
1412+
)
1413+
),
1414+
)
1415+
1416+
arg2fixturedefs: Dict[str, Sequence[FixtureDef[Any]]] = {}
1417+
names_closure = self.getfixtureclosure(
1418+
node,
1419+
initialnames,
1420+
arg2fixturedefs,
1421+
ignore_args=_get_direct_parametrize_args(node),
1422+
)
1423+
return FuncFixtureInfo(
1424+
argnames,
1425+
initialnames,
1426+
names_closure,
1427+
arg2fixturedefs,
14371428
)
1438-
return FuncFixtureInfo(argnames, initialnames, names_closure, arg2fixturedefs)
14391429

14401430
def pytest_plugin_registered(self, plugin: _PluggyPlugin) -> None:
14411431
nodeid = None
@@ -1468,45 +1458,38 @@ def _getautousenames(self, nodeid: str) -> Iterator[str]:
14681458

14691459
def getfixtureclosure(
14701460
self,
1471-
fixturenames: Tuple[str, ...],
14721461
parentnode: nodes.Node,
1462+
initialnames: Tuple[str],
1463+
arg2fixturedefs: Dict[str, Sequence[FixtureDef[Any]]],
14731464
ignore_args: Sequence[str] = (),
1474-
) -> Tuple[Tuple[str, ...], List[str], Dict[str, Sequence[FixtureDef[Any]]]]:
1465+
) -> List[str]:
14751466
# Collect the closure of all fixtures, starting with the given
1476-
# fixturenames as the initial set. As we have to visit all
1477-
# factory definitions anyway, we also return an arg2fixturedefs
1467+
# initialnames as the initial set. As we have to visit all
1468+
# factory definitions anyway, we also populate arg2fixturedefs
14781469
# mapping so that the caller can reuse it and does not have
14791470
# to re-discover fixturedefs again for each fixturename
14801471
# (discovering matching fixtures for a given name/node is expensive).
14811472

1482-
parentid = parentnode.nodeid
1483-
fixturenames_closure = list(self._getautousenames(parentid))
1473+
fixturenames_closure = list(initialnames)
14841474

14851475
def merge(otherlist: Iterable[str]) -> None:
14861476
for arg in otherlist:
14871477
if arg not in fixturenames_closure:
14881478
fixturenames_closure.append(arg)
14891479

1490-
merge(fixturenames)
1491-
1492-
# At this point, fixturenames_closure contains what we call "initialnames",
1493-
# which is a set of fixturenames the function immediately requests. We
1494-
# need to return it as well, so save this.
1495-
initialnames = tuple(fixturenames_closure)
1496-
1497-
arg2fixturedefs: Dict[str, Sequence[FixtureDef[Any]]] = {}
14981480
lastlen = -1
1481+
parentid = parentnode.nodeid
14991482
while lastlen != len(fixturenames_closure):
15001483
lastlen = len(fixturenames_closure)
15011484
for argname in fixturenames_closure:
15021485
if argname in ignore_args:
15031486
continue
1487+
if argname not in arg2fixturedefs:
1488+
fixturedefs = self.getfixturedefs(argname, parentid)
1489+
if fixturedefs:
1490+
arg2fixturedefs[argname] = fixturedefs
15041491
if argname in arg2fixturedefs:
1505-
continue
1506-
fixturedefs = self.getfixturedefs(argname, parentid)
1507-
if fixturedefs:
1508-
arg2fixturedefs[argname] = fixturedefs
1509-
merge(fixturedefs[-1].argnames)
1492+
merge(arg2fixturedefs[argname][-1].argnames)
15101493

15111494
def sort_by_scope(arg_name: str) -> Scope:
15121495
try:
@@ -1517,7 +1500,7 @@ def sort_by_scope(arg_name: str) -> Scope:
15171500
return fixturedefs[-1]._scope
15181501

15191502
fixturenames_closure.sort(key=sort_by_scope, reverse=True)
1520-
return initialnames, fixturenames_closure, arg2fixturedefs
1503+
return fixturenames_closure
15211504

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

src/_pytest/python.py

Lines changed: 33 additions & 9 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
@@ -60,6 +61,7 @@
6061
from _pytest.deprecated import NOSE_SUPPORT_METHOD
6162
from _pytest.fixtures import FixtureDef
6263
from _pytest.fixtures import FixtureRequest
64+
from _pytest.fixtures import _get_direct_parametrize_args
6365
from _pytest.fixtures import FuncFixtureInfo
6466
from _pytest.fixtures import get_scope_node
6567
from _pytest.main import Session
@@ -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,22 +503,29 @@ 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:
497527
yield Function.from_parent(self, name=name, fixtureinfo=fixtureinfo)
498528
else:
499-
# Direct parametrizations taking place in module/class-specific
500-
# `metafunc.parametrize` calls may have shadowed some fixtures, so make sure
501-
# we update what the function really needs a.k.a its fixture closure. Note that
502-
# direct parametrizations using `@pytest.mark.parametrize` have already been considered
503-
# into making the closure using `ignore_args` arg to `getfixtureclosure`.
504-
fixtureinfo.prune_dependency_tree()
505529

506530
for callspec in metafunc._calls:
507531
subname = f"{name}[{callspec.id}]"

testing/python/fixtures.py

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

0 commit comments

Comments
 (0)