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.
This commit is contained in:
Ran Benita 2024-01-06 13:12:02 +02:00
parent c2a4a8d518
commit 992d0f082f
7 changed files with 40 additions and 92 deletions

View File

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

View File

@ -424,9 +424,9 @@ class FixtureRequest(abc.ABC):
# We arrive here because of a dynamic call to # We arrive here because of a dynamic call to
# getfixturevalue(argname) usage which was naturally # getfixturevalue(argname) usage which was naturally
# not known at parsing/collection time. # not known at parsing/collection time.
assert self._pyfuncitem.parent is not None parent = self._pyfuncitem.parent
parentid = self._pyfuncitem.parent.nodeid assert parent is not None
fixturedefs = self._fixturemanager.getfixturedefs(argname, parentid) fixturedefs = self._fixturemanager.getfixturedefs(argname, parent)
if fixturedefs is not None: if fixturedefs is not None:
self._arg2fixturedefs[argname] = fixturedefs self._arg2fixturedefs[argname] = fixturedefs
# No fixtures defined with this name. # No fixtures defined with this name.
@ -846,9 +846,8 @@ class FixtureLookupError(LookupError):
available = set() available = set()
parent = self.request._pyfuncitem.parent parent = self.request._pyfuncitem.parent
assert parent is not None assert parent is not None
parentid = parent.nodeid
for name, fixturedefs in fm._arg2fixturedefs.items(): for name, fixturedefs in fm._arg2fixturedefs.items():
faclist = list(fm._matchfactories(fixturedefs, parentid)) faclist = list(fm._matchfactories(fixturedefs, parent))
if faclist: if faclist:
available.add(name) available.add(name)
if self.argname in available: if self.argname in available:
@ -989,9 +988,8 @@ class FixtureDef(Generic[FixtureValue]):
# The "base" node ID for the fixture. # The "base" node ID for the fixture.
# #
# This is a node ID prefix. A fixture is only available to a node (e.g. # This is a node ID prefix. A fixture is only available to a node (e.g.
# a `Function` item) if the fixture's baseid is a parent of the node's # a `Function` item) if the fixture's baseid is a nodeid of a parent of
# nodeid (see the `iterparentnodeids` function for what constitutes a # node.
# "parent" and a "prefix" in this context).
# #
# For a fixture found in a Collector's object (e.g. a `Module`s module, # For a fixture found in a Collector's object (e.g. a `Module`s module,
# a `Class`'s class), the baseid is the Collector's nodeid. # a `Class`'s class), the baseid is the Collector's nodeid.
@ -1482,7 +1480,7 @@ class FixtureManager:
else: else:
argnames = () argnames = ()
usefixturesnames = self._getusefixturesnames(node) usefixturesnames = self._getusefixturesnames(node)
autousenames = self._getautousenames(node.nodeid) autousenames = self._getautousenames(node)
initialnames = deduplicate_names(autousenames, usefixturesnames, argnames) initialnames = deduplicate_names(autousenames, usefixturesnames, argnames)
direct_parametrize_args = _get_direct_parametrize_args(node) direct_parametrize_args = _get_direct_parametrize_args(node)
@ -1517,10 +1515,10 @@ class FixtureManager:
self.parsefactories(plugin, nodeid) self.parsefactories(plugin, nodeid)
def _getautousenames(self, nodeid: str) -> Iterator[str]: def _getautousenames(self, node: nodes.Node) -> Iterator[str]:
"""Return the names of autouse fixtures applicable to nodeid.""" """Return the names of autouse fixtures applicable to node."""
for parentnodeid in nodes.iterparentnodeids(nodeid): for parentnode in reversed(list(nodes.iterparentnodes(node))):
basenames = self._nodeid_autousenames.get(parentnodeid) basenames = self._nodeid_autousenames.get(parentnode.nodeid)
if basenames: if basenames:
yield from basenames yield from basenames
@ -1542,7 +1540,6 @@ class FixtureManager:
# to re-discover fixturedefs again for each fixturename # to re-discover fixturedefs again for each fixturename
# (discovering matching fixtures for a given name/node is expensive). # (discovering matching fixtures for a given name/node is expensive).
parentid = parentnode.nodeid
fixturenames_closure = list(initialnames) fixturenames_closure = list(initialnames)
arg2fixturedefs: Dict[str, Sequence[FixtureDef[Any]]] = {} arg2fixturedefs: Dict[str, Sequence[FixtureDef[Any]]] = {}
@ -1554,7 +1551,7 @@ class FixtureManager:
continue continue
if argname in arg2fixturedefs: if argname in arg2fixturedefs:
continue continue
fixturedefs = self.getfixturedefs(argname, parentid) fixturedefs = self.getfixturedefs(argname, parentnode)
if fixturedefs: if fixturedefs:
arg2fixturedefs[argname] = fixturedefs arg2fixturedefs[argname] = fixturedefs
for arg in fixturedefs[-1].argnames: for arg in fixturedefs[-1].argnames:
@ -1726,7 +1723,7 @@ class FixtureManager:
self._nodeid_autousenames.setdefault(nodeid or "", []).extend(autousenames) self._nodeid_autousenames.setdefault(nodeid or "", []).extend(autousenames)
def getfixturedefs( def getfixturedefs(
self, argname: str, nodeid: str self, argname: str, node: nodes.Node
) -> Optional[Sequence[FixtureDef[Any]]]: ) -> Optional[Sequence[FixtureDef[Any]]]:
"""Get FixtureDefs for a fixture name which are applicable """Get FixtureDefs for a fixture name which are applicable
to a given node. to a given node.
@ -1737,18 +1734,18 @@ class FixtureManager:
an empty result is returned). an empty result is returned).
:param argname: Name of the fixture to search for. :param argname: Name of the fixture to search for.
:param nodeid: Full node id of the requesting test. :param node: The requesting Node.
""" """
try: try:
fixturedefs = self._arg2fixturedefs[argname] fixturedefs = self._arg2fixturedefs[argname]
except KeyError: except KeyError:
return None return None
return tuple(self._matchfactories(fixturedefs, nodeid)) return tuple(self._matchfactories(fixturedefs, node))
def _matchfactories( def _matchfactories(
self, fixturedefs: Iterable[FixtureDef[Any]], nodeid: str self, fixturedefs: Iterable[FixtureDef[Any]], node: nodes.Node
) -> Iterator[FixtureDef[Any]]: ) -> Iterator[FixtureDef[Any]]:
parentnodeids = set(nodes.iterparentnodeids(nodeid)) parentnodeids = {n.nodeid for n in nodes.iterparentnodes(node)}
for fixturedef in fixturedefs: for fixturedef in fixturedefs:
if fixturedef.baseid in parentnodeids: if fixturedef.baseid in parentnodeids:
yield fixturedef yield fixturedef

View File

@ -49,49 +49,13 @@ SEP = "/"
tracebackcutdir = Path(_pytest.__file__).parent tracebackcutdir = Path(_pytest.__file__).parent
def iterparentnodeids(nodeid: str) -> Iterator[str]: def iterparentnodes(node: "Node") -> Iterator["Node"]:
"""Return the parent node IDs of a given node ID, inclusive. """Return the parent nodes, including the node itself, from the node
upwards."""
For the node ID parent: Optional[Node] = node
while parent is not None:
"testing/code/test_excinfo.py::TestFormattedExcinfo::test_repr_source" yield parent
parent = parent.parent
the result would be
""
"testing"
"testing/code"
"testing/code/test_excinfo.py"
"testing/code/test_excinfo.py::TestFormattedExcinfo"
"testing/code/test_excinfo.py::TestFormattedExcinfo::test_repr_source"
Note that / components are only considered until the first ::.
"""
pos = 0
first_colons: Optional[int] = nodeid.find("::")
if first_colons == -1:
first_colons = None
# The root Session node - always present.
yield ""
# Eagerly consume SEP parts until first colons.
while True:
at = nodeid.find(SEP, pos, first_colons)
if at == -1:
break
if at > 0:
yield nodeid[:at]
pos = at + len(SEP)
# Eagerly consume :: parts.
while True:
at = nodeid.find("::", pos)
if at == -1:
break
if at > 0:
yield nodeid[:at]
pos = at + len("::")
# The node ID itself.
if nodeid:
yield nodeid
_NodeType = TypeVar("_NodeType", bound="Node") _NodeType = TypeVar("_NodeType", bound="Node")

View File

@ -1,7 +1,9 @@
anyio[curio,trio]==4.2.0 anyio[curio,trio]==4.2.0
django==5.0 django==5.0
pytest-asyncio==0.23.2 pytest-asyncio==0.23.2
pytest-bdd==7.0.1 # Temporarily not installed until pytest-bdd is fixed:
# https://github.com/pytest-dev/pytest/pull/11785
# pytest-bdd==7.0.1
pytest-cov==4.1.0 pytest-cov==4.1.0
pytest-django==4.7.0 pytest-django==4.7.0
pytest-flakes==4.0.5 pytest-flakes==4.0.5

View File

@ -1574,7 +1574,7 @@ class TestFixtureManagerParseFactories:
""" """
def test_hello(item, fm): def test_hello(item, fm):
for name in ("fm", "hello", "item"): for name in ("fm", "hello", "item"):
faclist = fm.getfixturedefs(name, item.nodeid) faclist = fm.getfixturedefs(name, item)
assert len(faclist) == 1 assert len(faclist) == 1
fac = faclist[0] fac = faclist[0]
assert fac.func.__name__ == name assert fac.func.__name__ == name
@ -1598,7 +1598,7 @@ class TestFixtureManagerParseFactories:
def hello(self, request): def hello(self, request):
return "class" return "class"
def test_hello(self, item, fm): def test_hello(self, item, fm):
faclist = fm.getfixturedefs("hello", item.nodeid) faclist = fm.getfixturedefs("hello", item)
print(faclist) print(faclist)
assert len(faclist) == 3 assert len(faclist) == 3
@ -1804,7 +1804,7 @@ class TestAutouseDiscovery:
""" """
from _pytest.pytester import get_public_names from _pytest.pytester import get_public_names
def test_check_setup(item, fm): def test_check_setup(item, fm):
autousenames = list(fm._getautousenames(item.nodeid)) autousenames = list(fm._getautousenames(item))
assert len(get_public_names(autousenames)) == 2 assert len(get_public_names(autousenames)) == 2
assert "perfunction2" in autousenames assert "perfunction2" in autousenames
assert "perfunction" in autousenames assert "perfunction" in autousenames

View File

@ -2,7 +2,6 @@ import re
import warnings import warnings
from pathlib import Path from pathlib import Path
from typing import cast from typing import cast
from typing import List
from typing import Type from typing import Type
import pytest import pytest
@ -12,29 +11,6 @@ from _pytest.pytester import Pytester
from _pytest.warning_types import PytestWarning from _pytest.warning_types import PytestWarning
@pytest.mark.parametrize(
("nodeid", "expected"),
(
("", [""]),
("a", ["", "a"]),
("aa/b", ["", "aa", "aa/b"]),
("a/b/c", ["", "a", "a/b", "a/b/c"]),
("a/bbb/c::D", ["", "a", "a/bbb", "a/bbb/c", "a/bbb/c::D"]),
("a/b/c::D::eee", ["", "a", "a/b", "a/b/c", "a/b/c::D", "a/b/c::D::eee"]),
("::xx", ["", "::xx"]),
# / only considered until first ::
("a/b/c::D/d::e", ["", "a", "a/b", "a/b/c", "a/b/c::D/d", "a/b/c::D/d::e"]),
# : alone is not a separator.
("a/b::D:e:f::g", ["", "a", "a/b", "a/b::D:e:f", "a/b::D:e:f::g"]),
# / not considered if a part of a test name
("a/b::c/d::e[/test]", ["", "a", "a/b", "a/b::c/d", "a/b::c/d::e[/test]"]),
),
)
def test_iterparentnodeids(nodeid: str, expected: List[str]) -> None:
result = list(nodes.iterparentnodeids(nodeid))
assert result == expected
def test_node_from_parent_disallowed_arguments() -> None: def test_node_from_parent_disallowed_arguments() -> None:
with pytest.raises(TypeError, match="session is"): with pytest.raises(TypeError, match="session is"):
nodes.Node.from_parent(None, session=None) # type: ignore[arg-type] nodes.Node.from_parent(None, session=None) # type: ignore[arg-type]

View File

@ -134,9 +134,11 @@ changedir = testing/plugins_integration
deps = -rtesting/plugins_integration/requirements.txt deps = -rtesting/plugins_integration/requirements.txt
setenv = setenv =
PYTHONPATH=. PYTHONPATH=.
# Command temporarily removed until pytest-bdd is fixed:
# https://github.com/pytest-dev/pytest/pull/11785
# pytest bdd_wallet.py
commands = commands =
pip check pip check
pytest bdd_wallet.py
pytest --cov=. simple_integration.py pytest --cov=. simple_integration.py
pytest --ds=django_settings simple_integration.py pytest --ds=django_settings simple_integration.py
pytest --html=simple.html simple_integration.py pytest --html=simple.html simple_integration.py