Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
3f76aab
Replace string nodeids with a structured NodeId internally
nicoddemus Jul 22, 2026
2b91f16
Add changelog entry for the NodeId refactor
nicoddemus Jul 22, 2026
d48740a
Turn nodeid string/NodeId conversion helpers into NodeId classmethods
nicoddemus Jul 22, 2026
b20da6e
Make NodeId._str a lazily-computed cached attribute
nicoddemus Jul 22, 2026
8603cbd
Improve docs
nicoddemus Jul 22, 2026
9942f36
Remove NodeId.fspath, redundant with NodeId.path
nicoddemus Jul 22, 2026
fa607c8
Assert the actual nodeid value in test_to_json_nodeid_wire_shape
nicoddemus Jul 22, 2026
543e4da
Split NodeId into NodeId (live collection) and OpaqueNodeId (external…
nicoddemus Jul 22, 2026
77d24ff
Fix docs build warnings from the NodeId/OpaqueNodeId refactor
nicoddemus Jul 22, 2026
6005798
Split NodeId into CollectionNodeId/ItemNodeId along the Collector/Ite…
nicoddemus Jul 22, 2026
0be32e6
Cache the original string directly in OpaqueNodeId.parse()
nicoddemus Jul 22, 2026
161294c
Add NodeId.to_opaque() and use OpaqueNodeId as LFPlugin/NFPlugin's so…
nicoddemus Jul 22, 2026
ffc3376
Add @override to _build_str implementations, moving the shim to compa…
nicoddemus Jul 22, 2026
3d60aa0
Rename NodeId.to_opaque to as_opaque, add OpaqueNodeId.as_opaque
nicoddemus Jul 23, 2026
5f72ff8
Use an explicit match statement in coerce_node_id
nicoddemus Jul 23, 2026
396982f
Review changes
nicoddemus Jul 23, 2026
e9bccc7
Move comment
nicoddemus Jul 23, 2026
6950878
Rename _pytest._nodeid to _pytest.nodeid
nicoddemus Jul 23, 2026
bb23f28
Fix stale CallSpec2 reference in ParamId docstring after rebase
nicoddemus Jul 23, 2026
de2b0e0
Fix grammar
nicoddemus Jul 24, 2026
b1e3e95
Use slots=True/kw_only=True on nodeid.py dataclasses
nicoddemus Jul 24, 2026
d495b3d
Doc changes
nicoddemus Jul 24, 2026
3ff11cd
Remove cross-type equality between CollectionNodeId/ItemNodeId/Opaque…
nicoddemus Jul 24, 2026
8277a2d
Remove str support from Node.__init__'s nodeid parameter
nicoddemus Jul 24, 2026
e580846
Add a runtime check that Node.__init__'s nodeid is a NodeId or None
nicoddemus Jul 24, 2026
09acd5d
Remove Node.__hash__
nicoddemus Jul 24, 2026
b6565f0
Fold _WithNodeId into BaseReport; use match in junitxml's node_reporter
nicoddemus Jul 24, 2026
481409d
Replace remaining nodeid string-splitting with OpaqueNodeId.path/.rest
nicoddemus Aug 5, 2026
2df09b9
Extend OpaqueNodeId to (path, names, params); convert mangle_test_add…
nicoddemus Aug 5, 2026
952e6ce
Drop ParamId; merge OpaqueNodeId into ItemNodeId
nicoddemus Aug 5, 2026
a2c2dbc
Collapse CollectionNodeId into a single NodeId class
nicoddemus Aug 5, 2026
17aef40
Remove stale type: ignore[misc] from test_nodes.py
nicoddemus Aug 5, 2026
599f551
Rename NodeId.leaf(name, params) -> child(name).with_params(params)
nicoddemus Aug 5, 2026
a995cdb
Tighten Node.parent type to Collector | None
nicoddemus Aug 5, 2026
4c4c6cc
Remove "Experimental/internal" notes from NodeId .id property docstrings
nicoddemus Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/14758.misc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Internal node ids (the ``::``-separated strings identifying collected items, e.g. ``path/to/test_file.py::TestClass::test_method[param]``) are now represented internally by a single structured :class:`~_pytest.nodeid.NodeId` dataclass instead of being repeatedly re-parsed as plain strings. The public ``nodeid: str`` attribute on nodes and reports is unchanged and remains fully backward compatible for plugins.
1 change: 1 addition & 0 deletions doc/en/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
("py:class", "_pytest.python_api.RaisesContext"),
("py:class", "_pytest.recwarn.WarningsChecker"),
("py:class", "_pytest.reports.BaseReport"),
("py:class", "_pytest.nodeid.NodeId"),
# Sphinx bugs(?)
("py:class", "RewriteHook"),
# Undocumented third parties
Expand Down
54 changes: 30 additions & 24 deletions src/_pytest/cacheprovider.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from _pytest.fixtures import fixture
from _pytest.fixtures import FixtureRequest
from _pytest.main import Session
from _pytest.nodeid import NodeId
from _pytest.nodes import Directory
from _pytest.nodes import File
from _pytest.reports import TestReport
Expand Down Expand Up @@ -271,7 +272,7 @@ def sort_key(node: nodes.Item | nodes.Collector) -> bool:

# Only filter with known failures.
if not self._collected_at_least_one_failure:
if not any(x.nodeid in lastfailed for x in result):
if not any(x.id in lastfailed for x in result):
return res
self.lfplugin.config.pluginmanager.register(
LFPluginCollSkipfiles(self.lfplugin), "lfplugin-collskip"
Expand All @@ -282,7 +283,7 @@ def sort_key(node: nodes.Item | nodes.Collector) -> bool:
result[:] = [
x
for x in result
if x.nodeid in lastfailed
if x.id in lastfailed
# Include any passed arguments (not trivial to filter).
or session.isinitpath(x.path)
# Keep all sub-collectors.
Expand All @@ -304,9 +305,7 @@ def pytest_make_collect_report(
if collector.path not in self.lfplugin._last_failed_paths:
self.lfplugin._skipped_files += 1

return CollectReport(
collector.nodeid, "passed", longrepr=None, result=[]
)
return CollectReport(collector.id, "passed", longrepr=None, result=[])
return None


Expand All @@ -318,7 +317,10 @@ def __init__(self, config: Config) -> None:
active_keys = "lf", "failedfirst"
self.active = any(config.getoption(key) for key in active_keys)
assert config.cache
self.lastfailed: dict[str, bool] = config.cache.get("cache/lastfailed", {})
self.lastfailed: dict[NodeId, bool] = {
NodeId.parse(k): v
for k, v in config.cache.get("cache/lastfailed", {}).items()
}
self._previously_failed_count: int | None = None
self._report_status: str | None = None
self._skipped_files = 0 # count skipped files during collection due to --lf
Expand All @@ -335,7 +337,7 @@ def get_last_failed_paths(self) -> set[Path]:
rootpath = self.config.rootpath
result = set()
for nodeid in self.lastfailed:
path = rootpath / nodeid.split("::")[0]
path = rootpath / nodeid.path
result.add(path)
result.update(path.parents)
return {x for x in result if x.exists()}
Expand All @@ -347,18 +349,19 @@ def pytest_report_collectionfinish(self) -> str | None:

def pytest_runtest_logreport(self, report: TestReport) -> None:
if (report.when == "call" and report.passed) or report.skipped:
self.lastfailed.pop(report.nodeid, None)
self.lastfailed.pop(report.id, None)
elif report.failed:
self.lastfailed[report.nodeid] = True
self.lastfailed[report.id] = True

def pytest_collectreport(self, report: CollectReport) -> None:
passed = report.outcome in ("passed", "skipped")
if passed:
if report.nodeid in self.lastfailed:
self.lastfailed.pop(report.nodeid)
self.lastfailed.update((item.nodeid, True) for item in report.result)
report_id = report.id
if report_id in self.lastfailed:
self.lastfailed.pop(report_id)
self.lastfailed.update((item.id, True) for item in report.result)
else:
self.lastfailed[report.nodeid] = True
self.lastfailed[report.id] = True

@hookimpl(wrapper=True, tryfirst=True)
def pytest_collection_modifyitems(
Expand All @@ -373,7 +376,7 @@ def pytest_collection_modifyitems(
previously_failed = []
previously_passed = []
for item in items:
if item.nodeid in self.lastfailed:
if item.id in self.lastfailed:
previously_failed.append(item)
else:
previously_passed.append(item)
Expand Down Expand Up @@ -418,9 +421,10 @@ def pytest_sessionfinish(self, session: Session) -> None:
return

assert config.cache is not None
current_lastfailed = {str(k): v for k, v in self.lastfailed.items()}
saved_lastfailed = config.cache.get("cache/lastfailed", {})
if saved_lastfailed != self.lastfailed:
config.cache.set("cache/lastfailed", self.lastfailed)
if saved_lastfailed != current_lastfailed:
config.cache.set("cache/lastfailed", current_lastfailed)


class NFPlugin:
Expand All @@ -430,27 +434,29 @@ def __init__(self, config: Config) -> None:
self.config = config
self.active = config.option.newfirst
assert config.cache is not None
self.cached_nodeids = set(config.cache.get("cache/nodeids", []))
self.cached_nodeids: set[NodeId] = {
NodeId.parse(s) for s in config.cache.get("cache/nodeids", [])
}

@hookimpl(wrapper=True, tryfirst=True)
def pytest_collection_modifyitems(self, items: list[nodes.Item]) -> Generator[None]:
res = yield

if self.active:
new_items: dict[str, nodes.Item] = {}
other_items: dict[str, nodes.Item] = {}
new_items: dict[NodeId, nodes.Item] = {}
other_items: dict[NodeId, nodes.Item] = {}
for item in items:
if item.nodeid not in self.cached_nodeids:
new_items[item.nodeid] = item
if item.id not in self.cached_nodeids:
new_items[item.id] = item
else:
other_items[item.nodeid] = item
other_items[item.id] = item

items[:] = self._get_increasing_order(
new_items.values()
) + self._get_increasing_order(other_items.values())
self.cached_nodeids.update(new_items)
else:
self.cached_nodeids.update(item.nodeid for item in items)
self.cached_nodeids.update(item.id for item in items)

return res

Expand All @@ -466,7 +472,7 @@ def pytest_sessionfinish(self) -> None:
return

assert config.cache is not None
config.cache.set("cache/nodeids", sorted(self.cached_nodeids))
config.cache.set("cache/nodeids", sorted(str(n) for n in self.cached_nodeids))


def pytest_addoption(parser: Parser) -> None:
Expand Down
11 changes: 11 additions & 0 deletions src/_pytest/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,3 +327,14 @@ def decorator(func):
return func

return decorator


if sys.version_info >= (3, 12):
from typing import override as override
else:
if TYPE_CHECKING:
from typing_extensions import override as override
else:

def override(func):
return func
17 changes: 7 additions & 10 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
from _pytest.config.argparsing import Parser
import _pytest.deprecated
import _pytest.hookspec
from _pytest.nodeid import NodeId
from _pytest.outcomes import fail
from _pytest.outcomes import Skipped
from _pytest.pathlib import absolutepath
Expand Down Expand Up @@ -646,11 +647,7 @@ def _set_initial_conftests(

anchors = []
for initial_path in args:
path = str(initial_path)
# remove node-id syntax
i = path.find("::")
if i != -1:
path = path[:i]
path = NodeId.parse(str(initial_path)).path
anchor = absolutepath(invocation_dir / path)
# Ensure we do not break if what appears to be an anchor
# is in fact a very long option (#10169, #11394).
Expand Down Expand Up @@ -1333,12 +1330,12 @@ def notify_exception(
def cwd_relative_nodeid(self, nodeid: str) -> str:
# nodeid's are relative to the rootpath, compute relative to cwd.
if self.invocation_params.dir != self.rootpath:
base_path_part, *nodeid_part = nodeid.split("::")
# Only process path part
fullpath = self.rootpath / base_path_part
oid = NodeId.parse(nodeid)
fullpath = self.rootpath / oid.path
relative_path = bestrelpath(self.invocation_params.dir, fullpath)

nodeid = "::".join([relative_path, *nodeid_part])
nodeid = (
relative_path if oid.rest is None else f"{relative_path}::{oid.rest}"
)
return nodeid

@classmethod
Expand Down
3 changes: 2 additions & 1 deletion src/_pytest/config/findpaths.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import iniconfig

from .exceptions import UsageError
from _pytest.nodeid import NodeId
from _pytest.outcomes import fail
from _pytest.pathlib import absolutepath
from _pytest.pathlib import commonpath
Expand Down Expand Up @@ -241,7 +242,7 @@ def is_option(x: str) -> bool:
return x.startswith("-")

def get_file_part_from_node_id(x: str) -> str:
return x.split("::", maxsplit=1)[0]
return NodeId.parse(x).path

def get_dir_from_path(path: Path) -> Path:
if path.is_dir():
Expand Down
Loading