Skip to content

Commit 677f4e4

Browse files
sadra-barikbinbluetech
authored andcommitted
Remove prune_dependency_tree and reuse getfixtureclosure logic
1 parent c8a4618 commit 677f4e4

File tree

5 files changed

+307
-74
lines changed

5 files changed

+307
-74
lines changed

src/_pytest/fixtures.py

Lines changed: 38 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -337,33 +337,6 @@ class FuncFixtureInfo:
337337
# sequence is ordered from furthest to closes to the function.
338338
name2fixturedefs: dict[str, Sequence[FixtureDef[Any]]]
339339

340-
def prune_dependency_tree(self) -> None:
341-
"""Recompute names_closure from initialnames and name2fixturedefs.
342-
343-
Can only reduce names_closure, which means that the new closure will
344-
always be a subset of the old one. The order is preserved.
345-
346-
This method is needed because direct parametrization may shadow some
347-
of the fixtures that were included in the originally built dependency
348-
tree. In this way the dependency tree can get pruned, and the closure
349-
of argnames may get reduced.
350-
"""
351-
closure: set[str] = set()
352-
working_set = set(self.initialnames)
353-
while working_set:
354-
argname = working_set.pop()
355-
# Argname may be something not included in the original names_closure,
356-
# in which case we ignore it. This currently happens with pseudo
357-
# FixtureDefs which wrap 'get_direct_param_fixture_func(request)'.
358-
# So they introduce the new dependency 'request' which might have
359-
# been missing in the original tree (closure).
360-
if argname not in closure and argname in self.names_closure:
361-
closure.add(argname)
362-
if argname in self.name2fixturedefs:
363-
working_set.update(self.name2fixturedefs[argname][-1].argnames)
364-
365-
self.names_closure[:] = sorted(closure, key=self.names_closure.index)
366-
367340

368341
class FixtureRequest(abc.ABC):
369342
"""The type of the ``request`` fixture.
@@ -968,7 +941,6 @@ def _eval_scope_callable(
968941
return result
969942

970943

971-
@final
972944
class FixtureDef(Generic[FixtureValue]):
973945
"""A container for a fixture definition.
974946
@@ -1136,6 +1108,26 @@ def __repr__(self) -> str:
11361108
return f"<FixtureDef argname={self.argname!r} scope={self.scope!r} baseid={self.baseid!r}>"
11371109

11381110

1111+
class IdentityFixtureDef(FixtureDef[FixtureValue]):
1112+
def __init__(
1113+
self,
1114+
config: Config,
1115+
argname: str,
1116+
scope: Scope | _ScopeName | Callable[[str, Config], _ScopeName] | None,
1117+
*,
1118+
_ispytest: bool = False,
1119+
):
1120+
super().__init__(
1121+
config,
1122+
"",
1123+
argname,
1124+
lambda request: request.param,
1125+
scope,
1126+
None,
1127+
_ispytest=_ispytest,
1128+
)
1129+
1130+
11391131
def resolve_fixture_function(
11401132
fixturedef: FixtureDef[FixtureValue], request: FixtureRequest
11411133
) -> _FixtureFunc[FixtureValue]:
@@ -1567,10 +1559,11 @@ def getfixtureinfo(
15671559
initialnames = deduplicate_names(autousenames, usefixturesnames, argnames)
15681560

15691561
direct_parametrize_args = _get_direct_parametrize_args(node)
1570-
1571-
names_closure, arg2fixturedefs = self.getfixtureclosure(
1562+
arg2fixturedefs: dict[str, Sequence[FixtureDef[Any]]] = {}
1563+
names_closure = self.getfixtureclosure(
15721564
parentnode=node,
15731565
initialnames=initialnames,
1566+
arg2fixturedefs=arg2fixturedefs,
15741567
ignore_args=direct_parametrize_args,
15751568
)
15761569

@@ -1622,30 +1615,32 @@ def getfixtureclosure(
16221615
self,
16231616
parentnode: nodes.Node,
16241617
initialnames: tuple[str, ...],
1618+
arg2fixturedefs: dict[str, Sequence[FixtureDef[Any]]],
16251619
ignore_args: AbstractSet[str],
1626-
) -> tuple[list[str], dict[str, Sequence[FixtureDef[Any]]]]:
1620+
) -> list[str]:
16271621
# Collect the closure of all fixtures, starting with the given
1628-
# fixturenames as the initial set. As we have to visit all
1629-
# factory definitions anyway, we also return an arg2fixturedefs
1630-
# mapping so that the caller can reuse it and does not have
1631-
# to re-discover fixturedefs again for each fixturename
1622+
# initialnames containing function arguments, `usefixture` markers
1623+
# and `autouse` fixtures as the initial set. As we have to visit all
1624+
# factory definitions anyway, we also populate arg2fixturedefs mapping
1625+
# for the args missing therein so that the caller can reuse it and does
1626+
# not have to re-discover fixturedefs again for each fixturename
16321627
# (discovering matching fixtures for a given name/node is expensive).
16331628

16341629
fixturenames_closure = list(initialnames)
16351630

1636-
arg2fixturedefs: dict[str, Sequence[FixtureDef[Any]]] = {}
16371631
lastlen = -1
16381632
while lastlen != len(fixturenames_closure):
16391633
lastlen = len(fixturenames_closure)
16401634
for argname in fixturenames_closure:
16411635
if argname in ignore_args:
16421636
continue
1643-
if argname in arg2fixturedefs:
1644-
continue
1645-
fixturedefs = self.getfixturedefs(argname, parentnode)
1646-
if fixturedefs:
1647-
arg2fixturedefs[argname] = fixturedefs
1648-
1637+
if argname not in arg2fixturedefs:
1638+
fixturedefs = self.getfixturedefs(argname, parentnode)
1639+
if fixturedefs:
1640+
arg2fixturedefs[argname] = fixturedefs
1641+
else:
1642+
fixturedefs = arg2fixturedefs[argname]
1643+
if fixturedefs and not isinstance(fixturedefs[-1], IdentityFixtureDef):
16491644
# Add dependencies from this fixture.
16501645
# If it overrides a fixture with the same name and requests
16511646
# it, also add dependencies from the overridden fixtures in
@@ -1667,7 +1662,7 @@ def sort_by_scope(arg_name: str) -> Scope:
16671662
return fixturedefs[-1]._scope
16681663

16691664
fixturenames_closure.sort(key=sort_by_scope, reverse=True)
1670-
return fixturenames_closure, arg2fixturedefs
1665+
return fixturenames_closure
16711666

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

src/_pytest/python.py

Lines changed: 40 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,10 @@
5151
from _pytest.config import hookimpl
5252
from _pytest.config.argparsing import Parser
5353
from _pytest.deprecated import check_ispytest
54-
from _pytest.fixtures import FixtureDef
55-
from _pytest.fixtures import FixtureRequest
54+
from _pytest.fixtures import _get_direct_parametrize_args
5655
from _pytest.fixtures import FuncFixtureInfo
5756
from _pytest.fixtures import get_scope_node
57+
from _pytest.fixtures import IdentityFixtureDef
5858
from _pytest.main import Session
5959
from _pytest.mark import ParameterSet
6060
from _pytest.mark.structures import _HiddenParam
@@ -448,8 +448,6 @@ def _genfunctions(self, name: str, funcobj) -> Iterator[Function]:
448448
definition = FunctionDefinition.from_parent(self, name=name, callobj=funcobj)
449449
fixtureinfo = definition._fixtureinfo
450450

451-
# pytest_generate_tests impls call metafunc.parametrize() which fills
452-
# metafunc._calls, the outcome of the hook.
453451
metafunc = Metafunc(
454452
definition=definition,
455453
fixtureinfo=fixtureinfo,
@@ -458,24 +456,34 @@ def _genfunctions(self, name: str, funcobj) -> Iterator[Function]:
458456
module=module,
459457
_ispytest=True,
460458
)
461-
methods = []
459+
460+
def prune_dependency_tree_if_test_is_directly_parametrized(
461+
metafunc: Metafunc,
462+
) -> None:
463+
# Direct (those with `indirect=False`) parametrizations taking place in
464+
# module/class-specific `pytest_generate_tests` hooks, a.k.a dynamic direct
465+
# parametrizations using `metafunc.parametrize`, may have shadowed some
466+
# fixtures, making some fixtures no longer reachable. Update the dependency
467+
# tree to reflect what the item really needs.
468+
# Note that direct parametrizations using `@pytest.mark.parametrize` have
469+
# already been considered into making the closure using the `ignore_args`
470+
# arg to `getfixtureclosure`.
471+
if metafunc._has_direct_parametrization:
472+
metafunc._update_dependency_tree()
473+
474+
methods = [prune_dependency_tree_if_test_is_directly_parametrized]
462475
if hasattr(module, "pytest_generate_tests"):
463476
methods.append(module.pytest_generate_tests)
464477
if cls is not None and hasattr(cls, "pytest_generate_tests"):
465478
methods.append(cls().pytest_generate_tests)
479+
480+
# pytest_generate_tests impls call metafunc.parametrize() which fills
481+
# metafunc._calls, the outcome of the hook.
466482
self.ihook.pytest_generate_tests.call_extra(methods, dict(metafunc=metafunc))
467483

468484
if not metafunc._calls:
469485
yield Function.from_parent(self, name=name, fixtureinfo=fixtureinfo)
470486
else:
471-
metafunc._recompute_direct_params_indices()
472-
# Direct parametrizations taking place in module/class-specific
473-
# `metafunc.parametrize` calls may have shadowed some fixtures, so make sure
474-
# we update what the function really needs a.k.a its fixture closure. Note that
475-
# direct parametrizations using `@pytest.mark.parametrize` have already been considered
476-
# into making the closure using `ignore_args` arg to `getfixtureclosure`.
477-
fixtureinfo.prune_dependency_tree()
478-
479487
for callspec in metafunc._calls:
480488
subname = f"{name}[{callspec.id}]" if callspec._idlist else name
481489
yield Function.from_parent(
@@ -1108,12 +1116,8 @@ def id(self) -> str:
11081116
return "-".join(self._idlist)
11091117

11101118

1111-
def get_direct_param_fixture_func(request: FixtureRequest) -> Any:
1112-
return request.param
1113-
1114-
11151119
# Used for storing pseudo fixturedefs for direct parametrization.
1116-
name2pseudofixturedef_key = StashKey[dict[str, FixtureDef[Any]]]()
1120+
name2pseudofixturedef_key = StashKey[dict[str, IdentityFixtureDef[Any]]]()
11171121

11181122

11191123
@final
@@ -1162,6 +1166,9 @@ def __init__(
11621166

11631167
self._params_directness: dict[str, Literal["indirect", "direct"]] = {}
11641168

1169+
# Whether it's ever been directly parametrized, i.e. with `indirect=False`.
1170+
self._has_direct_parametrization = False
1171+
11651172
def parametrize(
11661173
self,
11671174
argnames: str | Sequence[str],
@@ -1308,24 +1315,21 @@ def parametrize(
13081315
node = collector.session
13091316
else:
13101317
assert False, f"Unhandled missing scope: {scope}"
1311-
default: dict[str, FixtureDef[Any]] = {}
1318+
default: dict[str, IdentityFixtureDef[Any]] = {}
13121319
name2pseudofixturedef = node.stash.setdefault(
13131320
name2pseudofixturedef_key, default
13141321
)
13151322
for argname in argnames:
13161323
if arg_directness[argname] == "indirect":
13171324
continue
1325+
self._has_direct_parametrization = True
13181326
if name2pseudofixturedef is not None and argname in name2pseudofixturedef:
13191327
fixturedef = name2pseudofixturedef[argname]
13201328
else:
1321-
fixturedef = FixtureDef(
1322-
config=self.config,
1323-
baseid="",
1329+
fixturedef = IdentityFixtureDef(
1330+
config=self.definition.config,
13241331
argname=argname,
1325-
func=get_direct_param_fixture_func,
13261332
scope=scope_,
1327-
params=None,
1328-
ids=None,
13291333
_ispytest=True,
13301334
)
13311335
if name2pseudofixturedef is not None:
@@ -1485,11 +1489,17 @@ def _validate_if_using_arg_names(
14851489
pytrace=False,
14861490
)
14871491

1488-
def _recompute_direct_params_indices(self) -> None:
1489-
for argname, param_type in self._params_directness.items():
1490-
if param_type == "direct":
1491-
for i, callspec in enumerate(self._calls):
1492-
callspec.indices[argname] = i
1492+
def _update_dependency_tree(self) -> None:
1493+
definition = self.definition
1494+
assert definition.parent is not None
1495+
fm = definition.parent.session._fixturemanager
1496+
fixture_closure = fm.getfixtureclosure(
1497+
parentnode=definition,
1498+
initialnames=definition._fixtureinfo.initialnames,
1499+
arg2fixturedefs=definition._fixtureinfo.name2fixturedefs,
1500+
ignore_args=_get_direct_parametrize_args(definition),
1501+
)
1502+
definition._fixtureinfo.names_closure[:] = fixture_closure
14931503

14941504

14951505
def _find_parametrized_scope(

0 commit comments

Comments
 (0)