Skip to content

Commit 1531eed

Browse files
committed
fixtures: match fixtures based on actual node hierarchy, not textual nodeids
Refs #11662. --- Problem Each fixture definition has a "visibility", the `FixtureDef.baseid` attribute. This is nodeid-like string. When a certain `node` requests a certain fixture name, we match node's nodeid against the fixture definitions with this name. The matching currently happens on the *textual* representation of the nodeid - we split `node.nodeid` to its "parent nodeids" and then check if the fixture's `baseid` is in there. While this has worked so far, we really should try to avoid textual manipulation of nodeids as much as possible. It has also caused problem in an odd case of a `Package` in the root directory: the `Package` gets nodeid `.`, while a `Module` in it gets nodeid `test_module.py`. And textually, `.` is not a parent of `test_module.py`. --- Solution Avoid this entirely by just checking the node hierarchy itself. This is made possible by the fact that we now have proper `Directory` nodes (`Dir` or `Package`) for the entire hierarchy. Also do the same for `_getautousenames` which is a similar deal. The `iterparentnodeids` function is no longer used and is removed.
1 parent c2a4a8d commit 1531eed

File tree

5 files changed

+34
-90
lines changed

5 files changed

+34
-90
lines changed

changelog/TBD.trivial.rst

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Some were made to private functions which may affect plugins which access them:
2+
3+
- ``FixtureManager._getautousenames()`` now takes a ``Node`` itself instead of the nodeid.
4+
- ``FixtureManager.getfixturedefs()`` now takes the ``Node`` itself instead of the nodeid.
5+
- The ``_pytest.nodes.iterparentnodeids()`` function is removed without replacement.
6+
Prefer to traverse the node hierarchy itself instead.
7+
If you really need to, copy the function from the previous pytest release.

src/_pytest/fixtures.py

+17-20
Original file line numberDiff line numberDiff line change
@@ -424,9 +424,9 @@ def _getnextfixturedef(self, argname: str) -> "FixtureDef[Any]":
424424
# We arrive here because of a dynamic call to
425425
# getfixturevalue(argname) usage which was naturally
426426
# not known at parsing/collection time.
427-
assert self._pyfuncitem.parent is not None
428-
parentid = self._pyfuncitem.parent.nodeid
429-
fixturedefs = self._fixturemanager.getfixturedefs(argname, parentid)
427+
parent = self._pyfuncitem.parent
428+
assert parent is not None
429+
fixturedefs = self._fixturemanager.getfixturedefs(argname, parent)
430430
if fixturedefs is not None:
431431
self._arg2fixturedefs[argname] = fixturedefs
432432
# No fixtures defined with this name.
@@ -846,9 +846,8 @@ def formatrepr(self) -> "FixtureLookupErrorRepr":
846846
available = set()
847847
parent = self.request._pyfuncitem.parent
848848
assert parent is not None
849-
parentid = parent.nodeid
850849
for name, fixturedefs in fm._arg2fixturedefs.items():
851-
faclist = list(fm._matchfactories(fixturedefs, parentid))
850+
faclist = list(fm._matchfactories(fixturedefs, parent))
852851
if faclist:
853852
available.add(name)
854853
if self.argname in available:
@@ -989,9 +988,8 @@ def __init__(
989988
# The "base" node ID for the fixture.
990989
#
991990
# This is a node ID prefix. A fixture is only available to a node (e.g.
992-
# a `Function` item) if the fixture's baseid is a parent of the node's
993-
# nodeid (see the `iterparentnodeids` function for what constitutes a
994-
# "parent" and a "prefix" in this context).
991+
# a `Function` item) if the fixture's baseid is a nodeid of a parent of
992+
# node.
995993
#
996994
# For a fixture found in a Collector's object (e.g. a `Module`s module,
997995
# a `Class`'s class), the baseid is the Collector's nodeid.
@@ -1482,7 +1480,7 @@ def getfixtureinfo(
14821480
else:
14831481
argnames = ()
14841482
usefixturesnames = self._getusefixturesnames(node)
1485-
autousenames = self._getautousenames(node.nodeid)
1483+
autousenames = self._getautousenames(node)
14861484
initialnames = deduplicate_names(autousenames, usefixturesnames, argnames)
14871485

14881486
direct_parametrize_args = _get_direct_parametrize_args(node)
@@ -1517,10 +1515,10 @@ def pytest_plugin_registered(self, plugin: _PluggyPlugin) -> None:
15171515

15181516
self.parsefactories(plugin, nodeid)
15191517

1520-
def _getautousenames(self, nodeid: str) -> Iterator[str]:
1521-
"""Return the names of autouse fixtures applicable to nodeid."""
1522-
for parentnodeid in nodes.iterparentnodeids(nodeid):
1523-
basenames = self._nodeid_autousenames.get(parentnodeid)
1518+
def _getautousenames(self, node: nodes.Node) -> Iterator[str]:
1519+
"""Return the names of autouse fixtures applicable to node."""
1520+
for parentnode in reversed(list(nodes.iterparentnodes(node))):
1521+
basenames = self._nodeid_autousenames.get(parentnode.nodeid)
15241522
if basenames:
15251523
yield from basenames
15261524

@@ -1542,7 +1540,6 @@ def getfixtureclosure(
15421540
# to re-discover fixturedefs again for each fixturename
15431541
# (discovering matching fixtures for a given name/node is expensive).
15441542

1545-
parentid = parentnode.nodeid
15461543
fixturenames_closure = list(initialnames)
15471544

15481545
arg2fixturedefs: Dict[str, Sequence[FixtureDef[Any]]] = {}
@@ -1554,7 +1551,7 @@ def getfixtureclosure(
15541551
continue
15551552
if argname in arg2fixturedefs:
15561553
continue
1557-
fixturedefs = self.getfixturedefs(argname, parentid)
1554+
fixturedefs = self.getfixturedefs(argname, parentnode)
15581555
if fixturedefs:
15591556
arg2fixturedefs[argname] = fixturedefs
15601557
for arg in fixturedefs[-1].argnames:
@@ -1726,7 +1723,7 @@ def parsefactories( # noqa: F811
17261723
self._nodeid_autousenames.setdefault(nodeid or "", []).extend(autousenames)
17271724

17281725
def getfixturedefs(
1729-
self, argname: str, nodeid: str
1726+
self, argname: str, node: nodes.Node
17301727
) -> Optional[Sequence[FixtureDef[Any]]]:
17311728
"""Get FixtureDefs for a fixture name which are applicable
17321729
to a given node.
@@ -1737,18 +1734,18 @@ def getfixturedefs(
17371734
an empty result is returned).
17381735
17391736
:param argname: Name of the fixture to search for.
1740-
:param nodeid: Full node id of the requesting test.
1737+
:param node: The requesting Node.
17411738
"""
17421739
try:
17431740
fixturedefs = self._arg2fixturedefs[argname]
17441741
except KeyError:
17451742
return None
1746-
return tuple(self._matchfactories(fixturedefs, nodeid))
1743+
return tuple(self._matchfactories(fixturedefs, node))
17471744

17481745
def _matchfactories(
1749-
self, fixturedefs: Iterable[FixtureDef[Any]], nodeid: str
1746+
self, fixturedefs: Iterable[FixtureDef[Any]], node: nodes.Node
17501747
) -> Iterator[FixtureDef[Any]]:
1751-
parentnodeids = set(nodes.iterparentnodeids(nodeid))
1748+
parentnodeids = {n.nodeid for n in nodes.iterparentnodes(node)}
17521749
for fixturedef in fixturedefs:
17531750
if fixturedef.baseid in parentnodeids:
17541751
yield fixturedef

src/_pytest/nodes.py

+7-43
Original file line numberDiff line numberDiff line change
@@ -49,49 +49,13 @@
4949
tracebackcutdir = Path(_pytest.__file__).parent
5050

5151

52-
def iterparentnodeids(nodeid: str) -> Iterator[str]:
53-
"""Return the parent node IDs of a given node ID, inclusive.
54-
55-
For the node ID
56-
57-
"testing/code/test_excinfo.py::TestFormattedExcinfo::test_repr_source"
58-
59-
the result would be
60-
61-
""
62-
"testing"
63-
"testing/code"
64-
"testing/code/test_excinfo.py"
65-
"testing/code/test_excinfo.py::TestFormattedExcinfo"
66-
"testing/code/test_excinfo.py::TestFormattedExcinfo::test_repr_source"
67-
68-
Note that / components are only considered until the first ::.
69-
"""
70-
pos = 0
71-
first_colons: Optional[int] = nodeid.find("::")
72-
if first_colons == -1:
73-
first_colons = None
74-
# The root Session node - always present.
75-
yield ""
76-
# Eagerly consume SEP parts until first colons.
77-
while True:
78-
at = nodeid.find(SEP, pos, first_colons)
79-
if at == -1:
80-
break
81-
if at > 0:
82-
yield nodeid[:at]
83-
pos = at + len(SEP)
84-
# Eagerly consume :: parts.
85-
while True:
86-
at = nodeid.find("::", pos)
87-
if at == -1:
88-
break
89-
if at > 0:
90-
yield nodeid[:at]
91-
pos = at + len("::")
92-
# The node ID itself.
93-
if nodeid:
94-
yield nodeid
52+
def iterparentnodes(node: "Node") -> Iterator["Node"]:
53+
"""Return the parent nodes, including the node itself, from the node
54+
upwards."""
55+
parent: Optional[Node] = node
56+
while parent is not None:
57+
yield parent
58+
parent = parent.parent
9559

9660

9761
_NodeType = TypeVar("_NodeType", bound="Node")

testing/python/fixtures.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -1574,7 +1574,7 @@ def test_parsefactories_conftest(self, pytester: Pytester) -> None:
15741574
"""
15751575
def test_hello(item, fm):
15761576
for name in ("fm", "hello", "item"):
1577-
faclist = fm.getfixturedefs(name, item.nodeid)
1577+
faclist = fm.getfixturedefs(name, item)
15781578
assert len(faclist) == 1
15791579
fac = faclist[0]
15801580
assert fac.func.__name__ == name
@@ -1598,7 +1598,7 @@ class TestClass(object):
15981598
def hello(self, request):
15991599
return "class"
16001600
def test_hello(self, item, fm):
1601-
faclist = fm.getfixturedefs("hello", item.nodeid)
1601+
faclist = fm.getfixturedefs("hello", item)
16021602
print(faclist)
16031603
assert len(faclist) == 3
16041604
@@ -1804,7 +1804,7 @@ def test_parsefactories_conftest(self, pytester: Pytester) -> None:
18041804
"""
18051805
from _pytest.pytester import get_public_names
18061806
def test_check_setup(item, fm):
1807-
autousenames = list(fm._getautousenames(item.nodeid))
1807+
autousenames = list(fm._getautousenames(item))
18081808
assert len(get_public_names(autousenames)) == 2
18091809
assert "perfunction2" in autousenames
18101810
assert "perfunction" in autousenames

testing/test_nodes.py

-24
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import warnings
33
from pathlib import Path
44
from typing import cast
5-
from typing import List
65
from typing import Type
76

87
import pytest
@@ -12,29 +11,6 @@
1211
from _pytest.warning_types import PytestWarning
1312

1413

15-
@pytest.mark.parametrize(
16-
("nodeid", "expected"),
17-
(
18-
("", [""]),
19-
("a", ["", "a"]),
20-
("aa/b", ["", "aa", "aa/b"]),
21-
("a/b/c", ["", "a", "a/b", "a/b/c"]),
22-
("a/bbb/c::D", ["", "a", "a/bbb", "a/bbb/c", "a/bbb/c::D"]),
23-
("a/b/c::D::eee", ["", "a", "a/b", "a/b/c", "a/b/c::D", "a/b/c::D::eee"]),
24-
("::xx", ["", "::xx"]),
25-
# / only considered until first ::
26-
("a/b/c::D/d::e", ["", "a", "a/b", "a/b/c", "a/b/c::D/d", "a/b/c::D/d::e"]),
27-
# : alone is not a separator.
28-
("a/b::D:e:f::g", ["", "a", "a/b", "a/b::D:e:f", "a/b::D:e:f::g"]),
29-
# / not considered if a part of a test name
30-
("a/b::c/d::e[/test]", ["", "a", "a/b", "a/b::c/d", "a/b::c/d::e[/test]"]),
31-
),
32-
)
33-
def test_iterparentnodeids(nodeid: str, expected: List[str]) -> None:
34-
result = list(nodes.iterparentnodeids(nodeid))
35-
assert result == expected
36-
37-
3814
def test_node_from_parent_disallowed_arguments() -> None:
3915
with pytest.raises(TypeError, match="session is"):
4016
nodes.Node.from_parent(None, session=None) # type: ignore[arg-type]

0 commit comments

Comments
 (0)