Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

nodes: add Node.iterchain() function #11801

Merged
merged 1 commit into from Jan 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog/11801.improvement.rst
@@ -0,0 +1,2 @@
Added the :func:`iterparents() <_pytest.nodes.Node.iterparents>` helper method on nodes.
It is similar to :func:`listchain <_pytest.nodes.Node.listchain>`, but goes from bottom to top, and returns an iterator, not a list.
24 changes: 9 additions & 15 deletions src/_pytest/fixtures.py
Expand Up @@ -116,22 +116,16 @@
def get_scope_package(
node: nodes.Item,
fixturedef: "FixtureDef[object]",
) -> Optional[Union[nodes.Item, nodes.Collector]]:
) -> Optional[nodes.Node]:
from _pytest.python import Package

current: Optional[Union[nodes.Item, nodes.Collector]] = node
while current and (
not isinstance(current, Package) or current.nodeid != fixturedef.baseid
):
current = current.parent # type: ignore[assignment]
if current is None:
return node.session
return current
for parent in node.iterparents():
if isinstance(parent, Package) and parent.nodeid == fixturedef.baseid:
return parent

Check warning on line 124 in src/_pytest/fixtures.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/fixtures.py#L124

Added line #L124 was not covered by tests
return node.session


def get_scope_node(
node: nodes.Node, scope: Scope
) -> Optional[Union[nodes.Item, nodes.Collector]]:
def get_scope_node(node: nodes.Node, scope: Scope) -> Optional[nodes.Node]:
import _pytest.python

if scope is Scope.Function:
Expand Down Expand Up @@ -738,7 +732,7 @@
scope = self._scope
if scope is Scope.Function:
# This might also be a non-function Item despite its attribute name.
node: Optional[Union[nodes.Item, nodes.Collector]] = self._pyfuncitem
node: Optional[nodes.Node] = self._pyfuncitem
elif scope is Scope.Package:
node = get_scope_package(self._pyfuncitem, self._fixturedef)
else:
Expand Down Expand Up @@ -1513,7 +1507,7 @@

def _getautousenames(self, node: nodes.Node) -> Iterator[str]:
"""Return the names of autouse fixtures applicable to node."""
for parentnode in reversed(list(nodes.iterparentnodes(node))):
for parentnode in node.listchain():
basenames = self._nodeid_autousenames.get(parentnode.nodeid)
if basenames:
yield from basenames
Expand Down Expand Up @@ -1781,7 +1775,7 @@
def _matchfactories(
self, fixturedefs: Iterable[FixtureDef[Any]], node: nodes.Node
) -> Iterator[FixtureDef[Any]]:
parentnodeids = {n.nodeid for n in nodes.iterparentnodes(node)}
parentnodeids = {n.nodeid for n in node.iterparents()}
for fixturedef in fixturedefs:
if fixturedef.baseid in parentnodeids:
yield fixturedef
38 changes: 18 additions & 20 deletions src/_pytest/nodes.py
Expand Up @@ -49,15 +49,6 @@
tracebackcutdir = Path(_pytest.__file__).parent


def iterparentnodes(node: "Node") -> Iterator["Node"]:
"""Return the parent nodes, including the node itself, from the node
upwards."""
parent: Optional[Node] = node
while parent is not None:
yield parent
parent = parent.parent


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


Expand Down Expand Up @@ -265,12 +256,20 @@ def setup(self) -> None:
def teardown(self) -> None:
pass

def listchain(self) -> List["Node"]:
"""Return list of all parent collectors up to self, starting from
the root of collection tree.
def iterparents(self) -> Iterator["Node"]:
"""Iterate over all parent collectors starting from and including self
up to the root of the collection tree.

:returns: The nodes.
.. versionadded:: 8.1
"""
parent: Optional[Node] = self
while parent is not None:
yield parent
parent = parent.parent

def listchain(self) -> List["Node"]:
"""Return a list of all parent collectors starting from the root of the
collection tree down to and including self."""
chain = []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while at it maybe also add

Suggested change
chain = []
chain = [*self.iterchain()]
chain.reverse()

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On my machine (Python 3.11), for a length 7 chain, the existing implementation takes ~0.4 microseconds and reusing iterchain takes ~0.7 microseconds.

I vaguely recall listchain showing up in profiles so I decided to keep the faster implementation. If you think it's not worth it, I can change it as well.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, in that case let's skip until we get better profiles

Thanks for the detail

item: Optional[Node] = self
while item is not None:
Expand Down Expand Up @@ -319,7 +318,7 @@ def iter_markers_with_node(
:param name: If given, filter the results by the name attribute.
:returns: An iterator of (node, mark) tuples.
"""
for node in reversed(self.listchain()):
for node in self.iterparents():
for mark in node.own_markers:
if name is None or getattr(mark, "name", None) == name:
yield node, mark
Expand Down Expand Up @@ -363,17 +362,16 @@ def addfinalizer(self, fin: Callable[[], object]) -> None:
self.session._setupstate.addfinalizer(fin, self)

def getparent(self, cls: Type[_NodeType]) -> Optional[_NodeType]:
"""Get the next parent node (including self) which is an instance of
"""Get the closest parent node (including self) which is an instance of
the given class.

:param cls: The node class to search for.
:returns: The node, if found.
"""
current: Optional[Node] = self
while current and not isinstance(current, cls):
current = current.parent
assert current is None or isinstance(current, cls)
return current
for node in self.iterparents():
if isinstance(node, cls):
return node
return None

def _traceback_filter(self, excinfo: ExceptionInfo[BaseException]) -> Traceback:
return excinfo.traceback
Expand Down
4 changes: 1 addition & 3 deletions src/_pytest/python.py
Expand Up @@ -333,10 +333,8 @@ def _getobj(self):

def getmodpath(self, stopatmodule: bool = True, includemodule: bool = False) -> str:
"""Return Python path relative to the containing module."""
chain = self.listchain()
chain.reverse()
parts = []
for node in chain:
for node in self.iterparents():
name = node.name
if isinstance(node, Module):
name = os.path.splitext(name)[0]
Expand Down