From 2f95b56e0781b45c439b77cc723521fa51ba9232 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 11:05:45 +0200 Subject: [PATCH 01/27] Replace string nodeids with a structured NodeId internally pytest identified every collection-tree node with a plain "::"-joined nodeid string, repeatedly re-parsed (split/partition) at dozens of call sites for cache persistence, terminal/JUnit reporting, and stepwise/ subtests bookkeeping. Introduce a NodeId dataclass (src/_pytest/_nodeid.py) and use it internally as the dict/set key and equality type everywhere a nodeid was previously compared or looked up as a string, while keeping the existing nodeid: str property as a full backward-compatible surface for external plugins. Function's NodeId also carries structured per-parametrize-call data (ParamId: id/argnames/scope) when built from live collection data, laying groundwork for future scope-aware scheduling (e.g. in pytest-xdist) without committing any consumer to it yet. Co-Authored-By: Claude Sonnet 5 --- src/_pytest/_nodeid.py | 127 ++++++++++++++++++++++++++++++++++ src/_pytest/cacheprovider.py | 55 ++++++++------- src/_pytest/junitxml.py | 40 +++++++---- src/_pytest/nodes.py | 31 ++++++--- src/_pytest/python.py | 35 ++++++++-- src/_pytest/reports.py | 61 ++++++++++++++-- src/_pytest/runner.py | 2 +- src/_pytest/stepwise.py | 27 ++++++-- src/_pytest/subtests.py | 9 +-- src/_pytest/terminal.py | 25 +++---- testing/python/metafunc.py | 7 +- testing/test_cacheprovider.py | 2 +- testing/test_mark.py | 2 + testing/test_nodeid.py | 122 ++++++++++++++++++++++++++++++++ testing/test_reports.py | 14 ++++ testing/test_runner.py | 6 +- 16 files changed, 480 insertions(+), 85 deletions(-) create mode 100644 src/_pytest/_nodeid.py create mode 100644 testing/test_nodeid.py diff --git a/src/_pytest/_nodeid.py b/src/_pytest/_nodeid.py new file mode 100644 index 00000000000..4931e0e58e1 --- /dev/null +++ b/src/_pytest/_nodeid.py @@ -0,0 +1,127 @@ +"""Structured representation of a pytest "nodeid". + +A nodeid is a ``::``-separated string identifying a node in the collection +tree, e.g. ``path/to/test_file.py::TestClass::test_method[param]``. +:class:`NodeId` is the structured, internal representation of this concept; +the legacy string form remains available (via ``str(node_id)``) for +backward compatibility with external plugins. + +This module must stay a dependency-free leaf: it is imported from +``_pytest.nodes``, which is itself imported by ``_pytest.config``, so +importing anything from ``_pytest.config``/``_pytest.nodes`` here would +create a cycle. Importing :class:`~_pytest.scope.Scope` is safe, since +``_pytest.scope`` is itself documented as a dependency-free leaf module. +""" + +from __future__ import annotations + +import dataclasses + +from _pytest.scope import Scope + + +@dataclasses.dataclass(frozen=True) +class ParamId: + """One resolved id contributed by a single (possibly stacked) + ``parametrize()`` call. + + Multiple ``ParamId``s are joined with ``"-"`` to form the legacy + ``[bracket]`` content of a nodeid, mirroring + :attr:`_pytest.python.CallSpec2.param_ids`. + + ``argnames``/``scope`` are only known when built from live collection + data (see ``Function.__init__``); a :class:`NodeId` built from a plain + nodeid string (see :func:`parse_nodeid_path_and_names`) never + constructs one of these, since the individual per-call boundaries + cannot be reliably recovered once flattened into a string. + """ + + id: str + argnames: tuple[str, ...] = () + scope: Scope | None = None + + +@dataclasses.dataclass(frozen=True, eq=False) +class NodeId: + """Structured collection-tree address. + + :param path: + ``/``-normalized, rootpath-relative filesystem path. Empty string + for the session root. + :param names: + Ordered ``::``-segment names. The *last* element may or may not + carry an embedded ``[params]`` suffix -- see :attr:`params`. + :param params: + Ordered per-``parametrize()``-call ids. Only ever non-empty when + this ``NodeId`` was built directly from live collection data (see + ``Function.__init__``); a ``NodeId`` built from a plain string + (:func:`parse_nodeid_path_and_names`) always has ``params == ()``, + with any ``[params]`` bracket instead left glued, verbatim, onto + the last element of :attr:`names`. Both forms stringify identically + -- see :meth:`__str__`. + + .. note:: + + Equality and hashing are based on the canonical string form + (:meth:`__str__`), *not* on the raw ``path``/``names``/``params`` + fields. This is deliberate: two ``NodeId``s for the same logical + node must compare equal and hash equal regardless of whether one + was built from live collection data (rich ``params``) and the other + from a plain string (degraded, bracket glued into ``names``) -- + e.g. a currently-collected ``item.id`` vs. a ``NodeId`` read back + from an on-disk cache file. Only the string form is a reliable, + source-independent identity for a node. + """ + + path: str + names: tuple[str, ...] = () + params: tuple[ParamId, ...] = () + # Cached str(self) -- see __post_init__. Not a real constructor + # parameter: excluded from __init__/__repr__ via field(init=False). + _str: str = dataclasses.field(init=False, repr=False) + + def __post_init__(self) -> None: + base = "::".join((self.path, *self.names)) + if self.params: + base += "[" + "-".join(p.id for p in self.params) + "]" + # NodeId is frozen and str(self) is used on every __eq__/__hash__ + # call (see the class docstring) -- compute it once here rather + # than repeating the join/format work on every comparison. + object.__setattr__(self, "_str", base) + + def __str__(self) -> str: + return self._str + + def __eq__(self, other: object) -> bool: + if not isinstance(other, NodeId): + return NotImplemented + return self._str == other._str + + def __hash__(self) -> int: + return hash(self._str) + + def child(self, name: str, params: tuple[ParamId, ...] = ()) -> NodeId: + """Return a new NodeId for a child node with the given name.""" + return NodeId(self.path, (*self.names, name), params) + + @property + def fspath(self) -> str: + """The path portion of this node id.""" + return self.path + + +def parse_nodeid_path_and_names(nodeid: str) -> NodeId: + """Split a nodeid string into its path and name segments. + + This does **not** attempt to decompose any trailing ``[params]`` + bracket into individual :class:`ParamId` entries -- that structure only + exists in live collection data (see ``Function.__init__``) and cannot + be reliably recovered from an already-flattened string (``"-"`` is used + both to join sub-ids *within* one ``parametrize()`` call and to join + separate stacked calls, and either may itself contain value-derived + text with dashes in it). Any bracket present stays glued, verbatim, to + the last name segment, and the returned NodeId's ``params`` is always + empty. + """ + path, *names = nodeid.split("::") + return NodeId(path, tuple(names)) diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 6bcac1ad97a..793296f7cbd 100644 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -21,6 +21,8 @@ from .reports import CollectReport from _pytest import nodes from _pytest._io import TerminalWriter +from _pytest._nodeid import NodeId +from _pytest._nodeid import parse_nodeid_path_and_names from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import hookimpl @@ -271,7 +273,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" @@ -282,7 +284,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. @@ -304,9 +306,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 @@ -318,7 +318,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] = { + parse_nodeid_path_and_names(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 @@ -335,7 +338,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()} @@ -347,18 +350,18 @@ 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) + 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( @@ -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) @@ -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: @@ -430,27 +434,30 @@ 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 = { + parse_nodeid_path_and_names(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 @@ -466,7 +473,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: diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index ac78f50e618..7ac1c63632a 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -21,10 +21,14 @@ from _pytest import timing from _pytest._code.code import ExceptionRepr from _pytest._code.code import ReprFileLocation +from _pytest._nodeid import NodeId +from _pytest._nodeid import parse_nodeid_path_and_names from _pytest.config import Config from _pytest.config import filename_arg from _pytest.config.argparsing import Parser from _pytest.fixtures import FixtureRequest +from _pytest.reports import _WithNodeId +from _pytest.reports import BaseReport from _pytest.reports import TestReport from _pytest.stash import StashKey from _pytest.terminal import TerminalReporter @@ -82,8 +86,8 @@ def merge_family(left, right) -> None: class _NodeReporter: - def __init__(self, nodeid: str | TestReport, xml: LogXML) -> None: - self.id = nodeid + def __init__(self, node_id: NodeId, xml: LogXML) -> None: + self.id = node_id self.xml = xml self.add_stats = self.xml.add_stats self.family = self.xml.family @@ -317,7 +321,7 @@ def add_attr_noop(name: str, value: object) -> None: xml = request.config.stash.get(xml_key, None) if xml is not None: - node_reporter = xml.node_reporter(request.node.nodeid) + node_reporter = xml.node_reporter(request.node.id) attr_func = node_reporter.add_attribute return attr_func @@ -475,7 +479,7 @@ def __init__( self.stats: dict[str, int] = dict.fromkeys( ["error", "passed", "failure", "skipped"], 0 ) - self.node_reporters: dict[tuple[str | TestReport, object], _NodeReporter] = {} + self.node_reporters: dict[tuple[NodeId, object], _NodeReporter] = {} self.node_reporters_ordered: list[_NodeReporter] = [] self.global_properties: list[tuple[str, str]] = [] @@ -488,10 +492,10 @@ def __init__( self.family = "xunit1" def finalize(self, report: TestReport) -> None: - nodeid = getattr(report, "nodeid", report) + node_id = report.id # Local hack to handle xdist report order. workernode = getattr(report, "node", None) - reporter = self.node_reporters.pop((nodeid, workernode)) + reporter = self.node_reporters.pop((node_id, workernode)) for propname, propvalue in report.user_properties: reporter.add_property(propname, str(propvalue)) @@ -499,18 +503,28 @@ def finalize(self, report: TestReport) -> None: if reporter is not None: reporter.finalize() - def node_reporter(self, report: TestReport | str) -> _NodeReporter: - nodeid: str | TestReport = getattr(report, "nodeid", report) + def node_reporter(self, report: BaseReport | NodeId | str) -> _NodeReporter: + if isinstance(report, NodeId): + node_id = report + elif isinstance(report, str): + node_id = parse_nodeid_path_and_names(report) + elif isinstance(report, _WithNodeId): + # Covers both TestReport and CollectReport. + node_id = report.id + else: + # Some callers (and tests) pass duck-typed report-like objects + # that are neither of the above but do provide a nodeid. + node_id = parse_nodeid_path_and_names(report.nodeid) # Local hack to handle xdist report order. workernode = getattr(report, "node", None) - key = nodeid, workernode + key = node_id, workernode if key in self.node_reporters: # TODO: breaks for --dist=each return self.node_reporters[key] - reporter = _NodeReporter(nodeid, self) + reporter = _NodeReporter(node_id, self) self.node_reporters[key] = reporter self.node_reporters_ordered.append(reporter) @@ -564,7 +578,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: rep for rep in self.open_reports if ( - rep.nodeid == report.nodeid + rep.id == report.id and getattr(rep, "item_index", None) == report_ii and getattr(rep, "worker_id", None) == report_wid ) @@ -582,7 +596,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: # element for that item (#3850). self.cnt_double_fail_tests += int( ( - report.nodeid, + report.id, getattr(report, "node", None), ) in self.node_reporters @@ -611,7 +625,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: rep for rep in self.open_reports if ( - rep.nodeid == report.nodeid + rep.id == report.id and getattr(rep, "item_index", None) == report_ii and getattr(rep, "worker_id", None) == report_wid ) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index f0629c2daf7..8a2a57d6566 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -27,6 +27,8 @@ from _pytest._code.code import TerminalRepr from _pytest._code.code import Traceback from _pytest._code.code import TracebackStyle +from _pytest._nodeid import NodeId +from _pytest._nodeid import parse_nodeid_path_and_names from _pytest.compat import LEGACY_PATH from _pytest.compat import signature from _pytest.config import Config @@ -135,7 +137,7 @@ class Node(abc.ABC, metaclass=NodeMeta): # Note that __dict__ is still available. __slots__ = ( "__dict__", - "_nodeid", + "_id", "_store", "config", "name", @@ -152,7 +154,7 @@ def __init__( session: Session | None = None, fspath: None = None, path: Path | None = None, - nodeid: str | None = None, + nodeid: str | NodeId | None = None, ) -> None: #: A unique name within the scope of the parent node. self.name: str = name @@ -193,12 +195,14 @@ def __init__( self.extra_keyword_matches: set[str] = set() if nodeid is not None: - assert "::()" not in nodeid - self._nodeid = nodeid + if isinstance(nodeid, str): + self._id = parse_nodeid_path_and_names(nodeid) + else: + self._id = nodeid else: if not self.parent: raise TypeError("nodeid or parent must be provided") - self._nodeid = self.parent.nodeid + "::" + self.name + self._id = self.parent.id.child(self.name) #: A place where plugins can store information on the node for their #: own use. @@ -272,10 +276,21 @@ def warn(self, warning: Warning) -> None: @property def nodeid(self) -> str: """A ::-separated string denoting its collection tree address.""" - return self._nodeid + return str(self._id) + + @property + def id(self) -> NodeId: + """Structured collection tree address. + + .. note:: + + Experimental/internal: the shape of :class:`~_pytest._nodeid.NodeId` + may change in future releases. + """ + return self._id def __hash__(self) -> int: - return hash(self._nodeid) + return hash(self._id) def setup(self) -> None: pass @@ -658,7 +673,7 @@ def __init__( parent=None, config: Config | None = None, session: Session | None = None, - nodeid: str | None = None, + nodeid: str | NodeId | None = None, **kw, ) -> None: # The first two arguments are intentionally passed positionally, diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 4f168012c36..63d629e274e 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -42,6 +42,7 @@ from _pytest._code.code import TerminalRepr from _pytest._code.code import Traceback from _pytest._io.saferepr import saferepr +from _pytest._nodeid import ParamId from _pytest.compat import ascii_escaped from _pytest.compat import get_default_arg_names from _pytest.compat import get_real_func @@ -1168,8 +1169,9 @@ class CallSpec: # arg name -> parameter scope. # Used for sorting parametrized resources. _arg2scope: Mapping[str, Scope] = dataclasses.field(default_factory=dict) - # Parts which will be added to the item's name in `[..]` separated by "-". - _idlist: Sequence[str] = dataclasses.field(default_factory=tuple) + # One entry per (possibly stacked) parametrize() call, in order. Joined + # with "-" they form the item's name `[..]` suffix; see NodeId.params. + _idlist: Sequence[ParamId] = dataclasses.field(default_factory=tuple) # Marks which will be applied to the item. marks: list[Mark] = dataclasses.field(default_factory=list) @@ -1184,6 +1186,7 @@ def setmulti( param_index: int, nodeid: str, ) -> CallSpec: + argnames = tuple(argnames) params = self.params.copy() indices = self.indices.copy() arg2scope = dict(self._arg2scope) @@ -1195,11 +1198,18 @@ def setmulti( params[arg] = val indices[arg] = param_index arg2scope[arg] = scope + if id is HIDDEN_PARAM: + idlist = self._idlist + else: + idlist = [ + *self._idlist, + ParamId(id=id, argnames=argnames, scope=scope), + ] return CallSpec( params=params, indices=indices, _arg2scope=arg2scope, - _idlist=self._idlist if id is HIDDEN_PARAM else [*self._idlist, id], + _idlist=idlist, marks=[*self.marks, *normalize_mark_list(marks)], ) @@ -1211,7 +1221,13 @@ def getparam(self, name: str) -> object: @property def id(self) -> str: - return "-".join(self._idlist) + return "-".join(p.id for p in self._idlist) + + @property + def param_ids(self) -> tuple[ParamId, ...]: + """The ordered per-parametrize()-call ids, with full argnames/scope + detail. See :class:`~_pytest._nodeid.ParamId`.""" + return tuple(self._idlist) if TYPE_CHECKING: @@ -1689,7 +1705,16 @@ def __init__( fixtureinfo: FuncFixtureInfo | None = None, originalname: str | None = None, ) -> None: - super().__init__(name, parent, config=config, session=session) + # Build the NodeId explicitly from callspec (when parametrized) + # instead of going through Node.__init__'s generic + # `parent.id.child(name)` fallback, which would only see `name` + # (with any "[params]" suffix already glued on) and couldn't + # recover the per-parametrize()-call structure captured in + # callspec.param_ids. + base_name = originalname or name + params = callspec.param_ids if callspec is not None else () + node_id = parent.id.child(base_name, params) + super().__init__(name, parent, config=config, session=session, nodeid=node_id) if callobj is not NOTSET: self._obj = callobj diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 011a69db001..a347ff1cd66 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -29,6 +29,8 @@ from _pytest._code.code import ReprTraceback from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter +from _pytest._nodeid import NodeId +from _pytest._nodeid import parse_nodeid_path_and_names from _pytest.config import Config from _pytest.nodes import Collector from _pytest.nodes import Item @@ -302,7 +304,46 @@ def _format_exception_group_all_skipped_longrepr( return longrepr -class TestReport(BaseReport): +def _coerce_node_id(nodeid: str | NodeId) -> NodeId: + if isinstance(nodeid, NodeId): + return nodeid + return parse_nodeid_path_and_names(nodeid) + + +class _WithNodeId: + """Mixin providing the ``nodeid``/``id`` property pair, backed by + ``self._id: NodeId``. + + Deliberately not added to :class:`BaseReport` itself: several tests + define ad hoc ``BaseReport`` subclasses that set ``nodeid`` as a plain + class attribute, relying on ``BaseReport.__init__``'s generic + ``self.__dict__.update(kw)``. Keeping ``BaseReport`` untouched means + those subclasses are unaffected regardless of this plumbing. + """ + + _id: NodeId + + @property + def nodeid(self) -> str: + return str(self._id) + + @nodeid.setter + def nodeid(self, value: str) -> None: + self._id = parse_nodeid_path_and_names(value) + + @property + def id(self) -> NodeId: + """Structured collection tree address. + + .. note:: + + Experimental/internal: the shape of :class:`~_pytest._nodeid.NodeId` + may change in future releases. + """ + return self._id + + +class TestReport(_WithNodeId, BaseReport): """Basic test report object (also used for setup and teardown calls if they fail). @@ -317,7 +358,7 @@ class TestReport(BaseReport): def __init__( self, - nodeid: str, + nodeid: str | NodeId, location: tuple[str, int | None, str], keywords: Mapping[str, Any], outcome: Literal["passed", "failed", "skipped"], @@ -335,7 +376,7 @@ def __init__( **extra, ) -> None: #: Normalized collection nodeid. - self.nodeid = nodeid + self._id = _coerce_node_id(nodeid) #: A (filesystempath, lineno, domaininfo) tuple indicating the #: actual location of a test item - it might be different from the @@ -439,7 +480,7 @@ def from_item_and_call(cls, item: Item, call: CallInfo[None]) -> TestReport: for rwhen, key, content in item._report_sections: sections.append((f"Captured {key} {rwhen}", content)) return cls( - item.nodeid, + item.id, item.location, keywords, outcome, @@ -454,7 +495,7 @@ def from_item_and_call(cls, item: Item, call: CallInfo[None]) -> TestReport: @final -class CollectReport(BaseReport): +class CollectReport(_WithNodeId, BaseReport): """Collection report object. Reports can contain arbitrary extra attributes. @@ -464,7 +505,7 @@ class CollectReport(BaseReport): def __init__( self, - nodeid: str, + nodeid: str | NodeId, outcome: Literal["passed", "failed", "skipped"], longrepr: None | ExceptionInfo[BaseException] @@ -476,7 +517,7 @@ def __init__( **extra, ) -> None: #: Normalized collection nodeid. - self.nodeid = nodeid + self._id = _coerce_node_id(nodeid) #: Test outcome, always one of "passed", "failed", "skipped". self.outcome = outcome @@ -594,6 +635,12 @@ def serialize_exception_longrepr(rep: BaseReport) -> dict[str, Any]: return result d = report.__dict__.copy() + if "_id" in d: + # nodeid is a property (backed by self._id: NodeId) on TestReport/ + # CollectReport, so it's absent from __dict__ -- emit the wire-format + # "nodeid" string key that xdist and other consumers expect, and + # never expose the internal NodeId object on the wire. + d["nodeid"] = str(d.pop("_id")) if hasattr(report.longrepr, "toterminal"): if hasattr(report.longrepr, "reprtraceback") and hasattr( report.longrepr, "reprcrash" diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 3f03cfaff77..7efeea30489 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -431,7 +431,7 @@ def collect() -> list[Item | Collector]: errorinfo = CollectErrorRepr(errorinfo) longrepr = errorinfo result = call.result if not call.excinfo else None - rep = CollectReport(collector.nodeid, outcome, longrepr, result) + rep = CollectReport(collector.id, outcome, longrepr, result) rep.call = call # type: ignore # see collect_one_node return rep diff --git a/src/_pytest/stepwise.py b/src/_pytest/stepwise.py index 8901540eb59..c60797b94f8 100644 --- a/src/_pytest/stepwise.py +++ b/src/_pytest/stepwise.py @@ -7,6 +7,8 @@ from typing import TYPE_CHECKING from _pytest import nodes +from _pytest._nodeid import NodeId +from _pytest._nodeid import parse_nodeid_path_and_names from _pytest.cacheprovider import Cache from _pytest.config import Config from _pytest.config.argparsing import Parser @@ -70,7 +72,7 @@ def pytest_sessionfinish(session: Session) -> None: @dataclasses.dataclass class StepwiseCacheInfo: # The nodeid of the last failed test. - last_failed: str | None + last_failed: NodeId | None # The number of tests in the last time --stepwise was run. # We use this information as a simple way to invalidate the cache information, avoiding @@ -111,8 +113,11 @@ def _load_cached_info(self) -> StepwiseCacheInfo: cached_dict: dict[str, Any] | None = self.cache.get(STEPWISE_CACHE_DIR, None) if cached_dict: try: + last_failed: str | None = cached_dict["last_failed"] return StepwiseCacheInfo( - cached_dict["last_failed"], + parse_nodeid_path_and_names(last_failed) + if last_failed is not None + else None, cached_dict["last_test_count"], cached_dict["last_cache_date_str"], ) @@ -151,7 +156,7 @@ def pytest_collection_modifyitems( # Check all item nodes until we find a match on last failed. failed_index = None for index, item in enumerate(items): - if item.nodeid == self.cached_info.last_failed: + if item.id == self.cached_info.last_failed: failed_index = index break @@ -176,13 +181,13 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: if self.skip: # Remove test from the failed ones (if it exists) and unset the skip option # to make sure the following tests will not be skipped. - if report.nodeid == self.cached_info.last_failed: + if report.id == self.cached_info.last_failed: self.cached_info.last_failed = None self.skip = False else: # Mark test as the last failing and interrupt the test session. - self.cached_info.last_failed = report.nodeid + self.cached_info.last_failed = report.id assert self.session is not None self.session.shouldstop = ( "Test failed, continuing from this test next run." @@ -192,7 +197,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: # If the test was actually run and did pass. if report.when == "call": # Remove test from the failed ones, if exists. - if report.nodeid == self.cached_info.last_failed: + if report.id == self.cached_info.last_failed: self.cached_info.last_failed = None def pytest_report_collectionfinish(self) -> list[str] | None: @@ -206,4 +211,12 @@ def pytest_sessionfinish(self) -> None: # race conditions (#10641). return self.cached_info.update_date_to_now() - self.cache.set(STEPWISE_CACHE_DIR, dataclasses.asdict(self.cached_info)) + last_failed = self.cached_info.last_failed + self.cache.set( + STEPWISE_CACHE_DIR, + { + "last_failed": str(last_failed) if last_failed is not None else None, + "last_test_count": self.cached_info.last_test_count, + "last_cache_date_str": self.cached_info.last_cache_date_str, + }, + ) diff --git a/src/_pytest/subtests.py b/src/_pytest/subtests.py index 6ac3b5cd034..74c262e1e8d 100644 --- a/src/_pytest/subtests.py +++ b/src/_pytest/subtests.py @@ -20,6 +20,7 @@ from _pytest._code import ExceptionInfo from _pytest._io.saferepr import saferepr +from _pytest._nodeid import NodeId from _pytest.capture import CaptureFixture from _pytest.capture import FDCapture from _pytest.capture import SysCapture @@ -266,7 +267,7 @@ def __exit__( if sub_report.failed: failed_subtests = self.config.stash[failed_subtests_key] - failed_subtests[self.request.node.nodeid] += 1 + failed_subtests[self.request.node.id] += 1 with self.suspend_capture_ctx(): self.ihook.pytest_runtest_logreport(report=sub_report) @@ -354,9 +355,9 @@ def pytest_report_from_serializable(data: dict[str, Any]) -> SubtestReport | Non return None -# Dict of nodeid -> number of failed subtests. +# Dict of NodeId -> number of failed subtests. # Used to fail top-level tests that passed but contain failed subtests. -failed_subtests_key = StashKey[defaultdict[str, int]]() +failed_subtests_key = StashKey[defaultdict[NodeId, int]]() def pytest_configure(config: Config) -> None: @@ -408,7 +409,7 @@ def pytest_report_teststatus( return outcome, "-", f"SUBSKIPPED{description}" else: - failed_subtests_count = config.stash[failed_subtests_key][report.nodeid] + failed_subtests_count = config.stash[failed_subtests_key][report.id] # Top-level test, fail if it contains failed subtests and it has passed. if report.passed and failed_subtests_count > 0: report.outcome = "failed" diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index e46f8fb4c20..fed06a10830 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -38,6 +38,7 @@ from _pytest._code.code import ExceptionRepr from _pytest._io import TerminalWriter from _pytest._io.wcwidth import wcswidth +from _pytest._nodeid import NodeId import _pytest._version from _pytest.compat import running_on_ci from _pytest.config import _PluggyPlugin @@ -400,8 +401,8 @@ def __init__(self, config: Config, file: TextIO | None = None) -> None: # isatty should be a method but was wrongly implemented as a boolean. # We use CallableBool here to support both. self.isatty = compat.CallableBool(file.isatty()) - self._progress_nodeids_reported: set[str] = set() - self._timing_nodeids_reported: set[str] = set() + self._progress_nodeids_reported: set[NodeId] = set() + self._timing_nodeids_reported: set[NodeId] = set() self._show_progress_info = self._determine_show_progress_info() self._collect_report_last_write = timing.Instant() self._already_displayed_warnings: int | None = None @@ -650,7 +651,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: markup = {"yellow": True} else: markup = {} - self._progress_nodeids_reported.add(rep.nodeid) + self._progress_nodeids_reported.add(rep.id) if self.config.get_verbosity(Config.VERBOSITY_TEST_CASES) <= 0: self._tw.write(letter, **markup) # When running in xdist, the logreport and logfinish of multiple @@ -741,7 +742,7 @@ def _get_progress_information_message(self) -> str: ) current_location = all_reports[-1].location[0] not_reported = [ - r for r in all_reports if r.nodeid not in self._timing_nodeids_reported + r for r in all_reports if r.id not in self._timing_nodeids_reported ] tests_in_module = sum( i.location[0] == current_location for i in self._session.items @@ -753,7 +754,7 @@ def _get_progress_information_message(self) -> str: ) last_in_module = tests_completed == tests_in_module if self.showlongtestinfo or last_in_module: - self._timing_nodeids_reported.update(r.nodeid for r in not_reported) + self._timing_nodeids_reported.update(r.id for r in not_reported) return format_node_duration( sum(r.duration for r in not_reported if isinstance(r, TestReport)) ) @@ -928,7 +929,7 @@ def _printcollecteditems(self, items: Sequence[Item]) -> None: test_cases_verbosity = self.config.get_verbosity(Config.VERBOSITY_TEST_CASES) if test_cases_verbosity < 0: if test_cases_verbosity < -1: - counts = Counter(item.nodeid.split("::", 1)[0] for item in items) + counts = Counter(item.id.path for item in items) for name, count in sorted(counts.items()): self._tw.line(f"{name}: {count}") else: @@ -1167,18 +1168,18 @@ def summary_passes_combined( msg = self._getfailureheadline(rep) self.write_sep("_", msg, green=True, bold=True) self._outrep_summary(rep) - self._handle_teardown_sections(rep.nodeid) + self._handle_teardown_sections(rep.id) - def _get_teardown_reports(self, nodeid: str) -> list[TestReport]: + def _get_teardown_reports(self, node_id: NodeId) -> list[TestReport]: reports = self.getreports("") return [ report for report in reports - if report.when == "teardown" and report.nodeid == nodeid + if report.when == "teardown" and report.id == node_id ] - def _handle_teardown_sections(self, nodeid: str) -> None: - for report in self._get_teardown_reports(nodeid): + def _handle_teardown_sections(self, node_id: NodeId) -> None: + for report in self._get_teardown_reports(node_id): self.print_teardown_sections(report) def print_teardown_sections(self, rep: TestReport) -> None: @@ -1227,7 +1228,7 @@ def summary_failures_combined( msg = self._getfailureheadline(rep) self.write_sep("_", msg, red=True, bold=True) self._outrep_summary(rep) - self._handle_teardown_sections(rep.nodeid) + self._handle_teardown_sections(rep.id) def summary_errors(self) -> None: if self.config.option.tbstyle != "no": diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 7dfc8b9986b..5327d2f1af8 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -17,6 +17,7 @@ from _pytest import fixtures from _pytest import python +from _pytest._nodeid import NodeId from _pytest.compat import getfuncargnames from _pytest.compat import NOTSET from _pytest.outcomes import fail @@ -80,12 +81,14 @@ class SessionMock: @dataclasses.dataclass class DefinitionMock(python.FunctionDefinition): - _nodeid: str + _id: NodeId obj: object names = getfuncargnames(func) fixtureinfo: Any = FuncFixtureInfoMock(names) - definition: Any = DefinitionMock._create(obj=func, _nodeid="mock::nodeid") + definition: Any = DefinitionMock._create( + obj=func, _id=NodeId(path="mock", names=("nodeid",)) + ) definition._fixtureinfo = fixtureinfo definition.session = SessionMock(config, FixtureManagerMock({})) return python.Metafunc(definition, fixtureinfo, config, _ispytest=True) diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py index 7ac3f38ab64..88661d62ffc 100644 --- a/testing/test_cacheprovider.py +++ b/testing/test_cacheprovider.py @@ -113,7 +113,7 @@ def test_cache_failure_warns( "*/cacheprovider.py:*", " */cacheprovider.py:*: PytestCacheWarning: could not create cache path " f"{unwritable_cache_dir}/v/cache/nodeids: *", - ' config.cache.set("cache/nodeids", sorted(self.cached_nodeids))', + ' config.cache.set("cache/nodeids", sorted(str(n) for n in self.cached_nodeids))', "*1 failed, 2 warnings in*", ] ) diff --git a/testing/test_mark.py b/testing/test_mark.py index 253cda94503..9ea002a3345 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -5,6 +5,7 @@ import sys from unittest import mock +from _pytest._nodeid import NodeId from _pytest.config import ExitCode from _pytest.mark import MarkGenerator from _pytest.mark.structures import EMPTY_PARAMETERSET_OPTION @@ -1135,6 +1136,7 @@ def test_addmarker_order(pytester) -> None: session.own_markers = [] session.parent = None session.nodeid = "" + session.id = NodeId(path="") session.path = pytester.path node = Node.from_parent(session, name="Test") node.add_marker("foo") diff --git a/testing/test_nodeid.py b/testing/test_nodeid.py new file mode 100644 index 00000000000..783546506ac --- /dev/null +++ b/testing/test_nodeid.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from _pytest._nodeid import NodeId +from _pytest._nodeid import ParamId +from _pytest._nodeid import parse_nodeid_path_and_names +from _pytest.scope import Scope + + +class TestNodeId: + def test_str_root(self) -> None: + assert str(NodeId(path="")) == "" + + def test_str_path_only(self) -> None: + assert str(NodeId(path="a/b/test_c.py")) == "a/b/test_c.py" + + def test_str_with_names(self) -> None: + node_id = NodeId(path="a/test_b.py", names=("TestC", "test_d")) + assert str(node_id) == "a/test_b.py::TestC::test_d" + + def test_str_with_params(self) -> None: + node_id = NodeId( + path="a/test_b.py", + names=("test_c",), + params=(ParamId(id="1"), ParamId(id="x")), + ) + assert str(node_id) == "a/test_b.py::test_c[1-x]" + + def test_child(self) -> None: + parent = NodeId(path="a/test_b.py") + child = parent.child("TestC") + assert child == NodeId(path="a/test_b.py", names=("TestC",)) + grandchild = child.child("test_d") + assert grandchild == NodeId(path="a/test_b.py", names=("TestC", "test_d")) + + def test_child_with_params(self) -> None: + parent = NodeId(path="a/test_b.py") + params = (ParamId(id="1", argnames=("x",), scope=Scope.Function),) + child = parent.child("test_c", params) + assert child.params == params + assert str(child) == "a/test_b.py::test_c[1]" + + def test_fspath_property(self) -> None: + node_id = NodeId(path="a/test_b.py", names=("test_c",)) + assert node_id.fspath == "a/test_b.py" + + def test_eq_and_hash(self) -> None: + a = NodeId(path="a/test_b.py", names=("test_c",)) + b = NodeId(path="a/test_b.py", names=("test_c",)) + c = NodeId(path="a/test_b.py", names=("test_d",)) + assert a == b + assert hash(a) == hash(b) + assert a != c + # Usable as dict keys / set members. + assert {a: 1}[b] == 1 + assert {a, b, c} == {a, c} + + def test_eq_and_hash_with_params(self) -> None: + p1 = ParamId(id="1", argnames=("x",), scope=Scope.Function) + p2 = ParamId(id="1", argnames=("x",), scope=Scope.Function) + p3 = ParamId(id="1", argnames=("y",), scope=Scope.Function) + assert p1 == p2 + assert hash(p1) == hash(p2) + assert p1 != p3 + + def test_eq_and_hash_across_construction_paths(self) -> None: + """Equality/hash must be based on the canonical string form, not the + raw fields -- a NodeId built from live collection data (rich + params, clean names) and one built from a plain string (degraded, + bracket glued into names) must compare equal and hash equal when + they represent the same logical node. This matters for e.g. + comparing a currently-collected item.id against a NodeId read back + from an on-disk cache file. + """ + via_child = NodeId(path="a/test_b.py").child( + "test_c", (ParamId(id="1", argnames=("x",), scope=Scope.Function),) + ) + via_string = parse_nodeid_path_and_names("a/test_b.py::test_c[1]") + assert str(via_child) == str(via_string) + assert via_child.params != via_string.params # structurally different... + assert via_child == via_string # ...but logically the same node. + assert hash(via_child) == hash(via_string) + assert {via_child: "value"}[via_string] == "value" + + +class TestParseNodeidPathAndNames: + def test_root(self) -> None: + node_id = parse_nodeid_path_and_names("") + assert node_id == NodeId(path="") + assert str(node_id) == "" + + def test_path_only(self) -> None: + node_id = parse_nodeid_path_and_names("a/b/test_c.py") + assert node_id == NodeId(path="a/b/test_c.py") + + def test_with_names(self) -> None: + node_id = parse_nodeid_path_and_names("a/test_b.py::TestC::test_d") + assert node_id == NodeId(path="a/test_b.py", names=("TestC", "test_d")) + + def test_bracket_stays_glued_and_params_empty(self) -> None: + """The [params] bracket cannot be reliably decomposed from a plain + string, so it stays glued verbatim to the last name and params is + always empty -- see NodeId's docstring.""" + node_id = parse_nodeid_path_and_names("a/test_b.py::test_c[1-x]") + assert node_id.names == ("test_c[1-x]",) + assert node_id.params == () + + def test_round_trip_matches_original_string(self) -> None: + for s in ( + "", + "a/test_b.py", + "a/test_b.py::TestC", + "a/test_b.py::TestC::test_d", + "a/test_b.py::test_c[1-x]", + ): + assert str(parse_nodeid_path_and_names(s)) == s + + def test_equivalent_to_child_when_no_params(self) -> None: + """A NodeId built from a plain string and one built via .child() + with no params stringify identically for the same logical id.""" + via_string = parse_nodeid_path_and_names("a/test_b.py::test_c") + via_child = NodeId(path="a/test_b.py").child("test_c") + assert str(via_string) == str(via_child) diff --git a/testing/test_reports.py b/testing/test_reports.py index 23c5968bb52..2181770b6cc 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -76,6 +76,20 @@ def test_fail(): # Missing section attribute PR171 assert added_section in a.longrepr.sections + def test_to_json_nodeid_wire_shape(self, pytester: Pytester) -> None: + """The JSON wire payload must keep a plain top-level string "nodeid" + key (never an internal NodeId object / "_id" key) -- pytest-xdist + depends on this exact shape to serialize reports across processes. + """ + reprec = pytester.inline_runsource("def test_a(): pass") + reports = reprec.getreports("pytest_runtest_logreport") + rep = reports[1] + assert rep.when == "call" + d = rep._to_json() + assert d["nodeid"] == rep.nodeid + assert isinstance(d["nodeid"], str) + assert "_id" not in d + def test_reprentries_serialization_170(self, pytester: Pytester) -> None: """Regarding issue pytest-xdist#170 diff --git a/testing/test_runner.py b/testing/test_runner.py index 3cf6be69de9..b5c3839c79e 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -564,7 +564,11 @@ class TestClass(object): ) def test_report_extra_parameters(reporttype: type[reports.BaseReport]) -> None: args = list(inspect.signature(reporttype.__init__).parameters.keys())[1:] - basekw: dict[str, list[object]] = {arg: [] for arg in args} + basekw: dict[str, object] = {arg: [] for arg in args} + # nodeid must be a real string (unlike the other placeholder args here) -- + # it's parsed into a structured NodeId internally. + if "nodeid" in basekw: + basekw["nodeid"] = "" report = reporttype(newthing=1, **basekw) assert report.newthing == 1 From 0de5e2d5b6258906b39e187b23cd4dc4043f27e7 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 11:11:38 +0200 Subject: [PATCH 02/27] Add changelog entry for the NodeId refactor Co-Authored-By: Claude Sonnet 5 --- changelog/14758.misc.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/14758.misc.rst diff --git a/changelog/14758.misc.rst b/changelog/14758.misc.rst new file mode 100644 index 00000000000..1148931389f --- /dev/null +++ b/changelog/14758.misc.rst @@ -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 structured ``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. From 9e6c7cb306c193878c4fbe0c083aefe8e1569fc9 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 12:03:31 +0200 Subject: [PATCH 03/27] Turn nodeid string/NodeId conversion helpers into NodeId classmethods Replace the module-level parse_nodeid_path_and_names() and reports.py's private _coerce_node_id() with NodeId.parse()/NodeId.coerce() classmethods, and reword the Node.id/report.id docstrings to reference nodeid directly instead of the ad hoc "collection tree address" phrasing. Co-Authored-By: Claude Sonnet 5 --- src/_pytest/_nodeid.py | 49 +++++++++++++++++++++++------------- src/_pytest/cacheprovider.py | 6 ++--- src/_pytest/junitxml.py | 9 +++---- src/_pytest/nodes.py | 8 ++---- src/_pytest/reports.py | 15 +++-------- src/_pytest/stepwise.py | 5 +--- testing/test_nodeid.py | 27 +++++++++++++------- 7 files changed, 61 insertions(+), 58 deletions(-) diff --git a/src/_pytest/_nodeid.py b/src/_pytest/_nodeid.py index 4931e0e58e1..24906a71301 100644 --- a/src/_pytest/_nodeid.py +++ b/src/_pytest/_nodeid.py @@ -16,10 +16,15 @@ from __future__ import annotations import dataclasses +from typing import TYPE_CHECKING from _pytest.scope import Scope +if TYPE_CHECKING: + from typing_extensions import Self + + @dataclasses.dataclass(frozen=True) class ParamId: """One resolved id contributed by a single (possibly stacked) @@ -31,7 +36,7 @@ class ParamId: ``argnames``/``scope`` are only known when built from live collection data (see ``Function.__init__``); a :class:`NodeId` built from a plain - nodeid string (see :func:`parse_nodeid_path_and_names`) never + nodeid string (see :meth:`NodeId.parse`) never constructs one of these, since the individual per-call boundaries cannot be reliably recovered once flattened into a string. """ @@ -55,7 +60,7 @@ class NodeId: Ordered per-``parametrize()``-call ids. Only ever non-empty when this ``NodeId`` was built directly from live collection data (see ``Function.__init__``); a ``NodeId`` built from a plain string - (:func:`parse_nodeid_path_and_names`) always has ``params == ()``, + (:meth:`parse`) always has ``params == ()``, with any ``[params]`` bracket instead left glued, verbatim, onto the last element of :attr:`names`. Both forms stringify identically -- see :meth:`__str__`. @@ -109,19 +114,27 @@ def fspath(self) -> str: """The path portion of this node id.""" return self.path - -def parse_nodeid_path_and_names(nodeid: str) -> NodeId: - """Split a nodeid string into its path and name segments. - - This does **not** attempt to decompose any trailing ``[params]`` - bracket into individual :class:`ParamId` entries -- that structure only - exists in live collection data (see ``Function.__init__``) and cannot - be reliably recovered from an already-flattened string (``"-"`` is used - both to join sub-ids *within* one ``parametrize()`` call and to join - separate stacked calls, and either may itself contain value-derived - text with dashes in it). Any bracket present stays glued, verbatim, to - the last name segment, and the returned NodeId's ``params`` is always - empty. - """ - path, *names = nodeid.split("::") - return NodeId(path, tuple(names)) + @classmethod + def parse(cls, nodeid: str) -> Self: + """Split a nodeid string into its path and name segments. + + This does **not** attempt to decompose any trailing ``[params]`` + bracket into individual :class:`ParamId` entries -- that structure only + exists in live collection data (see ``Function.__init__``) and cannot + be reliably recovered from an already-flattened string (``"-"`` is used + both to join sub-ids *within* one ``parametrize()`` call and to join + separate stacked calls, and either may itself contain value-derived + text with dashes in it). Any bracket present stays glued, verbatim, to + the last name segment, and the returned NodeId's ``params`` is always + empty. + """ + path, *names = nodeid.split("::") + return cls(path, tuple(names)) + + @classmethod + def coerce(cls, nodeid: str | Self) -> Self: + """Return ``nodeid`` unchanged if already a :class:`NodeId`, otherwise + :meth:`parse` it.""" + if isinstance(nodeid, str): + return cls.parse(nodeid) + return nodeid diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 793296f7cbd..e7b0845b562 100644 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -22,7 +22,6 @@ from _pytest import nodes from _pytest._io import TerminalWriter from _pytest._nodeid import NodeId -from _pytest._nodeid import parse_nodeid_path_and_names from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import hookimpl @@ -319,7 +318,7 @@ def __init__(self, config: Config) -> None: self.active = any(config.getoption(key) for key in active_keys) assert config.cache self.lastfailed: dict[NodeId, bool] = { - parse_nodeid_path_and_names(k): v + NodeId.parse(k): v for k, v in config.cache.get("cache/lastfailed", {}).items() } self._previously_failed_count: int | None = None @@ -435,8 +434,7 @@ def __init__(self, config: Config) -> None: self.active = config.option.newfirst assert config.cache is not None self.cached_nodeids = { - parse_nodeid_path_and_names(s) - for s in config.cache.get("cache/nodeids", []) + NodeId.parse(s) for s in config.cache.get("cache/nodeids", []) } @hookimpl(wrapper=True, tryfirst=True) diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 7ac1c63632a..8ec7b496052 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -22,7 +22,6 @@ from _pytest._code.code import ExceptionRepr from _pytest._code.code import ReprFileLocation from _pytest._nodeid import NodeId -from _pytest._nodeid import parse_nodeid_path_and_names from _pytest.config import Config from _pytest.config import filename_arg from _pytest.config.argparsing import Parser @@ -504,17 +503,15 @@ def finalize(self, report: TestReport) -> None: reporter.finalize() def node_reporter(self, report: BaseReport | NodeId | str) -> _NodeReporter: - if isinstance(report, NodeId): - node_id = report - elif isinstance(report, str): - node_id = parse_nodeid_path_and_names(report) + if isinstance(report, (NodeId, str)): + node_id = NodeId.coerce(report) elif isinstance(report, _WithNodeId): # Covers both TestReport and CollectReport. node_id = report.id else: # Some callers (and tests) pass duck-typed report-like objects # that are neither of the above but do provide a nodeid. - node_id = parse_nodeid_path_and_names(report.nodeid) + node_id = NodeId.parse(report.nodeid) # Local hack to handle xdist report order. workernode = getattr(report, "node", None) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 8a2a57d6566..07548e0db82 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -28,7 +28,6 @@ from _pytest._code.code import Traceback from _pytest._code.code import TracebackStyle from _pytest._nodeid import NodeId -from _pytest._nodeid import parse_nodeid_path_and_names from _pytest.compat import LEGACY_PATH from _pytest.compat import signature from _pytest.config import Config @@ -195,10 +194,7 @@ def __init__( self.extra_keyword_matches: set[str] = set() if nodeid is not None: - if isinstance(nodeid, str): - self._id = parse_nodeid_path_and_names(nodeid) - else: - self._id = nodeid + self._id = NodeId.coerce(nodeid) else: if not self.parent: raise TypeError("nodeid or parent must be provided") @@ -280,7 +276,7 @@ def nodeid(self) -> str: @property def id(self) -> NodeId: - """Structured collection tree address. + """The structured (non-string) form of :attr:`nodeid`. .. note:: diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index a347ff1cd66..9f3c3a34770 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -30,7 +30,6 @@ from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter from _pytest._nodeid import NodeId -from _pytest._nodeid import parse_nodeid_path_and_names from _pytest.config import Config from _pytest.nodes import Collector from _pytest.nodes import Item @@ -304,12 +303,6 @@ def _format_exception_group_all_skipped_longrepr( return longrepr -def _coerce_node_id(nodeid: str | NodeId) -> NodeId: - if isinstance(nodeid, NodeId): - return nodeid - return parse_nodeid_path_and_names(nodeid) - - class _WithNodeId: """Mixin providing the ``nodeid``/``id`` property pair, backed by ``self._id: NodeId``. @@ -329,11 +322,11 @@ def nodeid(self) -> str: @nodeid.setter def nodeid(self, value: str) -> None: - self._id = parse_nodeid_path_and_names(value) + self._id = NodeId.parse(value) @property def id(self) -> NodeId: - """Structured collection tree address. + """The structured (non-string) form of :attr:`nodeid`. .. note:: @@ -376,7 +369,7 @@ def __init__( **extra, ) -> None: #: Normalized collection nodeid. - self._id = _coerce_node_id(nodeid) + self._id = NodeId.coerce(nodeid) #: A (filesystempath, lineno, domaininfo) tuple indicating the #: actual location of a test item - it might be different from the @@ -517,7 +510,7 @@ def __init__( **extra, ) -> None: #: Normalized collection nodeid. - self._id = _coerce_node_id(nodeid) + self._id = NodeId.coerce(nodeid) #: Test outcome, always one of "passed", "failed", "skipped". self.outcome = outcome diff --git a/src/_pytest/stepwise.py b/src/_pytest/stepwise.py index c60797b94f8..66ec0a5f6f4 100644 --- a/src/_pytest/stepwise.py +++ b/src/_pytest/stepwise.py @@ -8,7 +8,6 @@ from _pytest import nodes from _pytest._nodeid import NodeId -from _pytest._nodeid import parse_nodeid_path_and_names from _pytest.cacheprovider import Cache from _pytest.config import Config from _pytest.config.argparsing import Parser @@ -115,9 +114,7 @@ def _load_cached_info(self) -> StepwiseCacheInfo: try: last_failed: str | None = cached_dict["last_failed"] return StepwiseCacheInfo( - parse_nodeid_path_and_names(last_failed) - if last_failed is not None - else None, + NodeId.parse(last_failed) if last_failed is not None else None, cached_dict["last_test_count"], cached_dict["last_cache_date_str"], ) diff --git a/testing/test_nodeid.py b/testing/test_nodeid.py index 783546506ac..dc6ac969e63 100644 --- a/testing/test_nodeid.py +++ b/testing/test_nodeid.py @@ -2,7 +2,6 @@ from _pytest._nodeid import NodeId from _pytest._nodeid import ParamId -from _pytest._nodeid import parse_nodeid_path_and_names from _pytest.scope import Scope @@ -74,7 +73,7 @@ def test_eq_and_hash_across_construction_paths(self) -> None: via_child = NodeId(path="a/test_b.py").child( "test_c", (ParamId(id="1", argnames=("x",), scope=Scope.Function),) ) - via_string = parse_nodeid_path_and_names("a/test_b.py::test_c[1]") + via_string = NodeId.parse("a/test_b.py::test_c[1]") assert str(via_child) == str(via_string) assert via_child.params != via_string.params # structurally different... assert via_child == via_string # ...but logically the same node. @@ -82,25 +81,35 @@ def test_eq_and_hash_across_construction_paths(self) -> None: assert {via_child: "value"}[via_string] == "value" -class TestParseNodeidPathAndNames: +class TestNodeIdCoerce: + def test_from_str(self) -> None: + node_id = NodeId.coerce("a/test_b.py::test_c") + assert node_id == NodeId(path="a/test_b.py", names=("test_c",)) + + def test_from_node_id_returns_same_object(self) -> None: + node_id = NodeId(path="a/test_b.py", names=("test_c",)) + assert NodeId.coerce(node_id) is node_id + + +class TestNodeIdParse: def test_root(self) -> None: - node_id = parse_nodeid_path_and_names("") + node_id = NodeId.parse("") assert node_id == NodeId(path="") assert str(node_id) == "" def test_path_only(self) -> None: - node_id = parse_nodeid_path_and_names("a/b/test_c.py") + node_id = NodeId.parse("a/b/test_c.py") assert node_id == NodeId(path="a/b/test_c.py") def test_with_names(self) -> None: - node_id = parse_nodeid_path_and_names("a/test_b.py::TestC::test_d") + node_id = NodeId.parse("a/test_b.py::TestC::test_d") assert node_id == NodeId(path="a/test_b.py", names=("TestC", "test_d")) def test_bracket_stays_glued_and_params_empty(self) -> None: """The [params] bracket cannot be reliably decomposed from a plain string, so it stays glued verbatim to the last name and params is always empty -- see NodeId's docstring.""" - node_id = parse_nodeid_path_and_names("a/test_b.py::test_c[1-x]") + node_id = NodeId.parse("a/test_b.py::test_c[1-x]") assert node_id.names == ("test_c[1-x]",) assert node_id.params == () @@ -112,11 +121,11 @@ def test_round_trip_matches_original_string(self) -> None: "a/test_b.py::TestC::test_d", "a/test_b.py::test_c[1-x]", ): - assert str(parse_nodeid_path_and_names(s)) == s + assert str(NodeId.parse(s)) == s def test_equivalent_to_child_when_no_params(self) -> None: """A NodeId built from a plain string and one built via .child() with no params stringify identically for the same logical id.""" - via_string = parse_nodeid_path_and_names("a/test_b.py::test_c") + via_string = NodeId.parse("a/test_b.py::test_c") via_child = NodeId(path="a/test_b.py").child("test_c") assert str(via_string) == str(via_child) From c8e21d580bbb0d2446fa8b8ebd72c823d49bf58f Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 12:19:05 +0200 Subject: [PATCH 04/27] Make NodeId._str a lazily-computed cached attribute Compute and cache str(self) on first use inside __str__ itself instead of eagerly in __post_init__, and drop it as a declared dataclass field -- it's just an ad hoc attribute set via object.__setattr__ once computed. Co-Authored-By: Claude Sonnet 5 --- src/_pytest/_nodeid.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/_pytest/_nodeid.py b/src/_pytest/_nodeid.py index 24906a71301..991119e3f13 100644 --- a/src/_pytest/_nodeid.py +++ b/src/_pytest/_nodeid.py @@ -81,29 +81,28 @@ class NodeId: path: str names: tuple[str, ...] = () params: tuple[ParamId, ...] = () - # Cached str(self) -- see __post_init__. Not a real constructor - # parameter: excluded from __init__/__repr__ via field(init=False). - _str: str = dataclasses.field(init=False, repr=False) - def __post_init__(self) -> None: + def __str__(self) -> str: + # Lazily compute and cache the joined string on first access -- it's + # used on every __eq__/__hash__ call (see the class docstring), so + # it's worth not repeating the join/format work on every comparison. + # Not a dataclass field: just an ad hoc cached attribute. + cached: str | None = getattr(self, "_str", None) + if cached is not None: + return cached base = "::".join((self.path, *self.names)) if self.params: base += "[" + "-".join(p.id for p in self.params) + "]" - # NodeId is frozen and str(self) is used on every __eq__/__hash__ - # call (see the class docstring) -- compute it once here rather - # than repeating the join/format work on every comparison. object.__setattr__(self, "_str", base) - - def __str__(self) -> str: - return self._str + return base def __eq__(self, other: object) -> bool: if not isinstance(other, NodeId): return NotImplemented - return self._str == other._str + return str(self) == str(other) def __hash__(self) -> int: - return hash(self._str) + return hash(str(self)) def child(self, name: str, params: tuple[ParamId, ...] = ()) -> NodeId: """Return a new NodeId for a child node with the given name.""" From 36ed5fe05663a04531016d21e5a5e5c6f32b8041 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 12:24:59 +0200 Subject: [PATCH 05/27] Improve docs --- src/_pytest/_nodeid.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/_pytest/_nodeid.py b/src/_pytest/_nodeid.py index 991119e3f13..778900bdf35 100644 --- a/src/_pytest/_nodeid.py +++ b/src/_pytest/_nodeid.py @@ -117,23 +117,22 @@ def fspath(self) -> str: def parse(cls, nodeid: str) -> Self: """Split a nodeid string into its path and name segments. - This does **not** attempt to decompose any trailing ``[params]`` + **Use with caution**: This does **not** attempt to decompose any trailing ``[params]`` bracket into individual :class:`ParamId` entries -- that structure only exists in live collection data (see ``Function.__init__``) and cannot - be reliably recovered from an already-flattened string (``"-"`` is used - both to join sub-ids *within* one ``parametrize()`` call and to join - separate stacked calls, and either may itself contain value-derived - text with dashes in it). Any bracket present stays glued, verbatim, to - the last name segment, and the returned NodeId's ``params`` is always - empty. + be reliably recovered from an already-flattened string. """ path, *names = nodeid.split("::") return cls(path, tuple(names)) @classmethod def coerce(cls, nodeid: str | Self) -> Self: - """Return ``nodeid`` unchanged if already a :class:`NodeId`, otherwise - :meth:`parse` it.""" + """ + Return ``nodeid`` unchanged if already a :class:`NodeId`, otherwise + :meth:`parse` it. + + **Use with caution**, see :meth:`parse`. + """ if isinstance(nodeid, str): return cls.parse(nodeid) return nodeid From 628ce3a2bfd89dc0f7ac4ffdd396d6e2010a88ca Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 12:33:48 +0200 Subject: [PATCH 06/27] Remove NodeId.fspath, redundant with NodeId.path Co-Authored-By: Claude Sonnet 5 --- src/_pytest/_nodeid.py | 5 ----- testing/test_nodeid.py | 4 ---- 2 files changed, 9 deletions(-) diff --git a/src/_pytest/_nodeid.py b/src/_pytest/_nodeid.py index 778900bdf35..8cae2752abf 100644 --- a/src/_pytest/_nodeid.py +++ b/src/_pytest/_nodeid.py @@ -108,11 +108,6 @@ def child(self, name: str, params: tuple[ParamId, ...] = ()) -> NodeId: """Return a new NodeId for a child node with the given name.""" return NodeId(self.path, (*self.names, name), params) - @property - def fspath(self) -> str: - """The path portion of this node id.""" - return self.path - @classmethod def parse(cls, nodeid: str) -> Self: """Split a nodeid string into its path and name segments. diff --git a/testing/test_nodeid.py b/testing/test_nodeid.py index dc6ac969e63..15430137e1d 100644 --- a/testing/test_nodeid.py +++ b/testing/test_nodeid.py @@ -38,10 +38,6 @@ def test_child_with_params(self) -> None: assert child.params == params assert str(child) == "a/test_b.py::test_c[1]" - def test_fspath_property(self) -> None: - node_id = NodeId(path="a/test_b.py", names=("test_c",)) - assert node_id.fspath == "a/test_b.py" - def test_eq_and_hash(self) -> None: a = NodeId(path="a/test_b.py", names=("test_c",)) b = NodeId(path="a/test_b.py", names=("test_c",)) From 24423d054276886c0c6f6f8ee0db48ace1e9c660 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 12:33:54 +0200 Subject: [PATCH 07/27] Assert the actual nodeid value in test_to_json_nodeid_wire_shape Co-Authored-By: Claude Sonnet 5 --- testing/test_reports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/test_reports.py b/testing/test_reports.py index 2181770b6cc..e5c5126b29c 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -86,8 +86,8 @@ def test_to_json_nodeid_wire_shape(self, pytester: Pytester) -> None: rep = reports[1] assert rep.when == "call" d = rep._to_json() + assert d["nodeid"] == "test_to_json_nodeid_wire_shape.py::test_a" assert d["nodeid"] == rep.nodeid - assert isinstance(d["nodeid"], str) assert "_id" not in d def test_reprentries_serialization_170(self, pytester: Pytester) -> None: From b9739512b87949bb4e38c005d50a2799c0957150 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 14:32:05 +0200 Subject: [PATCH 08/27] Split NodeId into NodeId (live collection) and OpaqueNodeId (external strings) NodeId.parse()/coerce() let any external string be turned into a NodeId that looked structurally trustworthy (had .names/.params fields) while actually lying about them -- a parametrized test's bracket would get glued into .names[-1] with .params falsely staying empty, since that structure can't be reliably recovered once flattened into a string. Split the concept in two: NodeId is now only ever built from live collection data (direct construction, or .child() chaining off an already-live parent), so its .params can always be trusted. OpaqueNodeId is the honest counterpart for nodeids reconstructed from external sources (the on-disk lastfailed/nodeids/stepwise caches, xdist's JSON wire format, a duck-typed report-like object's .nodeid) -- it only knows path and an unparsed rest, with no claim to structured names or params. Both remain hashable and compare equal by canonical string form across types, so a live item.id still matches a cache-loaded OpaqueNodeId for the same node. Containers that only ever hold one kind stay narrowly typed; the few that legitimately mix live and boundary-sourced ids in the same run (e.g. NFPlugin.cached_nodeids, seeded from cache then updated with live items) are typed as a NodeId | OpaqueNodeId union. Co-Authored-By: Claude Sonnet 5 --- changelog/14758.misc.rst | 2 +- src/_pytest/_nodeid.py | 126 +++++++++++++++++++----------- src/_pytest/cacheprovider.py | 9 ++- src/_pytest/junitxml.py | 12 ++- src/_pytest/nodes.py | 13 +++- src/_pytest/reports.py | 19 +++-- src/_pytest/stepwise.py | 7 +- src/_pytest/subtests.py | 5 +- src/_pytest/terminal.py | 9 ++- testing/test_nodeid.py | 144 ++++++++++++++++++++++++----------- 10 files changed, 232 insertions(+), 114 deletions(-) diff --git a/changelog/14758.misc.rst b/changelog/14758.misc.rst index 1148931389f..59a64f1071b 100644 --- a/changelog/14758.misc.rst +++ b/changelog/14758.misc.rst @@ -1 +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 structured ``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. +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 structured dataclasses instead of being repeatedly re-parsed as plain strings: a ``NodeId`` built exclusively from live collection data, and a separate ``OpaqueNodeId`` for nodeids reconstructed from external sources (on-disk cache files, xdist's JSON wire format) whose parametrization details can't be reliably recovered. The public ``nodeid: str`` attribute on nodes and reports is unchanged and remains fully backward compatible for plugins. diff --git a/src/_pytest/_nodeid.py b/src/_pytest/_nodeid.py index 8cae2752abf..f98306d9676 100644 --- a/src/_pytest/_nodeid.py +++ b/src/_pytest/_nodeid.py @@ -2,9 +2,18 @@ A nodeid is a ``::``-separated string identifying a node in the collection tree, e.g. ``path/to/test_file.py::TestClass::test_method[param]``. -:class:`NodeId` is the structured, internal representation of this concept; -the legacy string form remains available (via ``str(node_id)``) for -backward compatibility with external plugins. +:class:`NodeId` is the structured, internal representation of this concept, +but it is only ever built from live collection data -- so that its +``params`` field can be trusted: a nodeid string's trailing ``[params]`` +bracket cannot be reliably decomposed back into individual +``parametrize()``-call boundaries once flattened (``"-"`` is used both to +join sub-ids within one call and to join separate stacked calls), so a +:class:`NodeId` built from an external string would either have to fabricate +that structure or silently lie about not having it. :class:`OpaqueNodeId` is +the honest alternative for that case: it only knows the ``path`` and an +unparsed ``rest``, and makes no claim to structured names or params. The +legacy ``::``-joined string form remains available (via ``str(node_id)``) +for backward compatibility with external plugins, for both types. This module must stay a dependency-free leaf: it is imported from ``_pytest.nodes``, which is itself imported by ``_pytest.config``, so @@ -16,15 +25,11 @@ from __future__ import annotations import dataclasses -from typing import TYPE_CHECKING +from typing import overload from _pytest.scope import Scope -if TYPE_CHECKING: - from typing_extensions import Self - - @dataclasses.dataclass(frozen=True) class ParamId: """One resolved id contributed by a single (possibly stacked) @@ -35,10 +40,9 @@ class ParamId: :attr:`_pytest.python.CallSpec2.param_ids`. ``argnames``/``scope`` are only known when built from live collection - data (see ``Function.__init__``); a :class:`NodeId` built from a plain - nodeid string (see :meth:`NodeId.parse`) never - constructs one of these, since the individual per-call boundaries - cannot be reliably recovered once flattened into a string. + data (see ``Function.__init__``) -- a :class:`NodeId` never has one of + these guessed from a string; see :class:`OpaqueNodeId` for the + string-boundary case, which has no ``ParamId``s at all. """ id: str @@ -50,6 +54,12 @@ class ParamId: class NodeId: """Structured collection-tree address. + Only ever constructed from live collection data: either directly (for + trivial/root cases, e.g. the session root or a bare collector path) or + via :meth:`child`, chaining off an already-live parent's ``.id``. There + is deliberately no way to build one from an arbitrary string -- see the + module docstring and :class:`OpaqueNodeId`. + :param path: ``/``-normalized, rootpath-relative filesystem path. Empty string for the session root. @@ -57,24 +67,16 @@ class NodeId: Ordered ``::``-segment names. The *last* element may or may not carry an embedded ``[params]`` suffix -- see :attr:`params`. :param params: - Ordered per-``parametrize()``-call ids. Only ever non-empty when - this ``NodeId`` was built directly from live collection data (see - ``Function.__init__``); a ``NodeId`` built from a plain string - (:meth:`parse`) always has ``params == ()``, - with any ``[params]`` bracket instead left glued, verbatim, onto - the last element of :attr:`names`. Both forms stringify identically - -- see :meth:`__str__`. + Ordered per-``parametrize()``-call ids. .. note:: Equality and hashing are based on the canonical string form (:meth:`__str__`), *not* on the raw ``path``/``names``/``params`` - fields. This is deliberate: two ``NodeId``s for the same logical - node must compare equal and hash equal regardless of whether one - was built from live collection data (rich ``params``) and the other - from a plain string (degraded, bracket glued into ``names``) -- - e.g. a currently-collected ``item.id`` vs. a ``NodeId`` read back - from an on-disk cache file. Only the string form is a reliable, + fields, and compare equal across :class:`NodeId`/:class:`OpaqueNodeId` + -- e.g. a currently-collected ``item.id`` (``NodeId``) must compare + equal to a matching entry read back from an on-disk cache file + (``OpaqueNodeId``). Only the string form is a reliable, source-independent identity for a node. """ @@ -97,7 +99,7 @@ def __str__(self) -> str: return base def __eq__(self, other: object) -> bool: - if not isinstance(other, NodeId): + if not isinstance(other, (NodeId, OpaqueNodeId)): return NotImplemented return str(self) == str(other) @@ -108,26 +110,60 @@ def child(self, name: str, params: tuple[ParamId, ...] = ()) -> NodeId: """Return a new NodeId for a child node with the given name.""" return NodeId(self.path, (*self.names, name), params) - @classmethod - def parse(cls, nodeid: str) -> Self: - """Split a nodeid string into its path and name segments. - **Use with caution**: This does **not** attempt to decompose any trailing ``[params]`` - bracket into individual :class:`ParamId` entries -- that structure only - exists in live collection data (see ``Function.__init__``) and cannot - be reliably recovered from an already-flattened string. - """ - path, *names = nodeid.split("::") - return cls(path, tuple(names)) +@dataclasses.dataclass(frozen=True, eq=False) +class OpaqueNodeId: + """A nodeid reconstructed from an external string source (an on-disk + cache file, an xdist JSON wire payload, a duck-typed report-like + object's ``.nodeid`` attribute, ...), rather than from live collection. + + Unlike :class:`NodeId`, this makes no claim to structured names or + params: everything after the first ``"::"`` is left opaque and unsplit, + since it cannot be reliably decomposed (see the module docstring). There + is no ``.child()`` -- nothing ever builds further collection-tree + structure on top of a boundary-sourced id. + """ + + path: str + # Everything after the first "::", left opaque/unsplit. None means no + # "::" was present at all (distinct from "" after a trailing "::", for + # lossless round-tripping through str.partition()). + rest: str | None = None @classmethod - def coerce(cls, nodeid: str | Self) -> Self: - """ - Return ``nodeid`` unchanged if already a :class:`NodeId`, otherwise - :meth:`parse` it. - - **Use with caution**, see :meth:`parse`. - """ - if isinstance(nodeid, str): - return cls.parse(nodeid) + def parse(cls, nodeid: str) -> OpaqueNodeId: + """Split a nodeid string into its path and an opaque remainder.""" + path, sep, rest = nodeid.partition("::") + return cls(path, rest if sep else None) + + def __str__(self) -> str: + # Same lazy-cache-on-first-access rationale as NodeId.__str__ -- + # these live in hot dict/set paths (--lf/--nf/--sw caches), hashed + # and compared once per collected item per run. + cached: str | None = getattr(self, "_str", None) + if cached is not None: + return cached + base = self.path if self.rest is None else f"{self.path}::{self.rest}" + object.__setattr__(self, "_str", base) + return base + + def __eq__(self, other: object) -> bool: + if not isinstance(other, (NodeId, OpaqueNodeId)): + return NotImplemented + return str(self) == str(other) + + def __hash__(self) -> int: + return hash(str(self)) + + +@overload +def coerce_node_id(nodeid: NodeId) -> NodeId: ... +@overload +def coerce_node_id(nodeid: str) -> OpaqueNodeId: ... +def coerce_node_id(nodeid: str | NodeId) -> NodeId | OpaqueNodeId: + """Return ``nodeid`` unchanged if already a :class:`NodeId` (live + collection data); otherwise treat it as an external nodeid string and + wrap it in an :class:`OpaqueNodeId`.""" + if isinstance(nodeid, NodeId): return nodeid + return OpaqueNodeId.parse(nodeid) diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index e7b0845b562..31306c22e16 100644 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -22,6 +22,7 @@ from _pytest import nodes from _pytest._io import TerminalWriter from _pytest._nodeid import NodeId +from _pytest._nodeid import OpaqueNodeId from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import hookimpl @@ -317,8 +318,8 @@ 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[NodeId, bool] = { - NodeId.parse(k): v + self.lastfailed: dict[NodeId | OpaqueNodeId, bool] = { + OpaqueNodeId.parse(k): v for k, v in config.cache.get("cache/lastfailed", {}).items() } self._previously_failed_count: int | None = None @@ -433,8 +434,8 @@ def __init__(self, config: Config) -> None: self.config = config self.active = config.option.newfirst assert config.cache is not None - self.cached_nodeids = { - NodeId.parse(s) for s in config.cache.get("cache/nodeids", []) + self.cached_nodeids: set[NodeId | OpaqueNodeId] = { + OpaqueNodeId.parse(s) for s in config.cache.get("cache/nodeids", []) } @hookimpl(wrapper=True, tryfirst=True) diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 8ec7b496052..ede547ca2f4 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -21,7 +21,9 @@ from _pytest import timing from _pytest._code.code import ExceptionRepr from _pytest._code.code import ReprFileLocation +from _pytest._nodeid import coerce_node_id from _pytest._nodeid import NodeId +from _pytest._nodeid import OpaqueNodeId from _pytest.config import Config from _pytest.config import filename_arg from _pytest.config.argparsing import Parser @@ -85,7 +87,7 @@ def merge_family(left, right) -> None: class _NodeReporter: - def __init__(self, node_id: NodeId, xml: LogXML) -> None: + def __init__(self, node_id: NodeId | OpaqueNodeId, xml: LogXML) -> None: self.id = node_id self.xml = xml self.add_stats = self.xml.add_stats @@ -478,7 +480,9 @@ def __init__( self.stats: dict[str, int] = dict.fromkeys( ["error", "passed", "failure", "skipped"], 0 ) - self.node_reporters: dict[tuple[NodeId, object], _NodeReporter] = {} + self.node_reporters: dict[ + tuple[NodeId | OpaqueNodeId, object], _NodeReporter + ] = {} self.node_reporters_ordered: list[_NodeReporter] = [] self.global_properties: list[tuple[str, str]] = [] @@ -504,14 +508,14 @@ def finalize(self, report: TestReport) -> None: def node_reporter(self, report: BaseReport | NodeId | str) -> _NodeReporter: if isinstance(report, (NodeId, str)): - node_id = NodeId.coerce(report) + node_id = coerce_node_id(report) elif isinstance(report, _WithNodeId): # Covers both TestReport and CollectReport. node_id = report.id else: # Some callers (and tests) pass duck-typed report-like objects # that are neither of the above but do provide a nodeid. - node_id = NodeId.parse(report.nodeid) + node_id = OpaqueNodeId.parse(report.nodeid) # Local hack to handle xdist report order. workernode = getattr(report, "node", None) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 07548e0db82..e0afc4e9630 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -194,7 +194,18 @@ def __init__( self.extra_keyword_matches: set[str] = set() if nodeid is not None: - self._id = NodeId.coerce(nodeid) + if isinstance(nodeid, NodeId): + self._id = nodeid + else: + # Every real caller here passes either "" (session root) or + # a bare collector path (never containing "::") -- Function, + # the only node type with real params/brackets, always + # pre-builds a full NodeId via parent.id.child(...) instead + # of reaching this branch. So this split can never see a + # "[params]" bracket, and .params legitimately staying () + # here is correct, not a lossy guess. + node_path, *names = nodeid.split("::") + self._id = NodeId(node_path, tuple(names)) else: if not self.parent: raise TypeError("nodeid or parent must be provided") diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 9f3c3a34770..834dd066143 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -29,7 +29,9 @@ from _pytest._code.code import ReprTraceback from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter +from _pytest._nodeid import coerce_node_id from _pytest._nodeid import NodeId +from _pytest._nodeid import OpaqueNodeId from _pytest.config import Config from _pytest.nodes import Collector from _pytest.nodes import Item @@ -305,7 +307,12 @@ def _format_exception_group_all_skipped_longrepr( class _WithNodeId: """Mixin providing the ``nodeid``/``id`` property pair, backed by - ``self._id: NodeId``. + ``self._id: NodeId | OpaqueNodeId``. + + A report's id is a ``NodeId`` when built from live collection data (see + ``TestReport.from_item_and_call``) and an ``OpaqueNodeId`` when built + from an external string (e.g. JSON-deserialized via ``_from_json``, or + assigned through the ``nodeid`` setter below). Deliberately not added to :class:`BaseReport` itself: several tests define ad hoc ``BaseReport`` subclasses that set ``nodeid`` as a plain @@ -314,7 +321,7 @@ class attribute, relying on ``BaseReport.__init__``'s generic those subclasses are unaffected regardless of this plumbing. """ - _id: NodeId + _id: NodeId | OpaqueNodeId @property def nodeid(self) -> str: @@ -322,10 +329,10 @@ def nodeid(self) -> str: @nodeid.setter def nodeid(self, value: str) -> None: - self._id = NodeId.parse(value) + self._id = OpaqueNodeId.parse(value) @property - def id(self) -> NodeId: + def id(self) -> NodeId | OpaqueNodeId: """The structured (non-string) form of :attr:`nodeid`. .. note:: @@ -369,7 +376,7 @@ def __init__( **extra, ) -> None: #: Normalized collection nodeid. - self._id = NodeId.coerce(nodeid) + self._id = coerce_node_id(nodeid) #: A (filesystempath, lineno, domaininfo) tuple indicating the #: actual location of a test item - it might be different from the @@ -510,7 +517,7 @@ def __init__( **extra, ) -> None: #: Normalized collection nodeid. - self._id = NodeId.coerce(nodeid) + self._id = coerce_node_id(nodeid) #: Test outcome, always one of "passed", "failed", "skipped". self.outcome = outcome diff --git a/src/_pytest/stepwise.py b/src/_pytest/stepwise.py index 66ec0a5f6f4..6398699a083 100644 --- a/src/_pytest/stepwise.py +++ b/src/_pytest/stepwise.py @@ -8,6 +8,7 @@ from _pytest import nodes from _pytest._nodeid import NodeId +from _pytest._nodeid import OpaqueNodeId from _pytest.cacheprovider import Cache from _pytest.config import Config from _pytest.config.argparsing import Parser @@ -71,7 +72,7 @@ def pytest_sessionfinish(session: Session) -> None: @dataclasses.dataclass class StepwiseCacheInfo: # The nodeid of the last failed test. - last_failed: NodeId | None + last_failed: NodeId | OpaqueNodeId | None # The number of tests in the last time --stepwise was run. # We use this information as a simple way to invalidate the cache information, avoiding @@ -114,7 +115,9 @@ def _load_cached_info(self) -> StepwiseCacheInfo: try: last_failed: str | None = cached_dict["last_failed"] return StepwiseCacheInfo( - NodeId.parse(last_failed) if last_failed is not None else None, + OpaqueNodeId.parse(last_failed) + if last_failed is not None + else None, cached_dict["last_test_count"], cached_dict["last_cache_date_str"], ) diff --git a/src/_pytest/subtests.py b/src/_pytest/subtests.py index 74c262e1e8d..960265d5c50 100644 --- a/src/_pytest/subtests.py +++ b/src/_pytest/subtests.py @@ -21,6 +21,7 @@ from _pytest._code import ExceptionInfo from _pytest._io.saferepr import saferepr from _pytest._nodeid import NodeId +from _pytest._nodeid import OpaqueNodeId from _pytest.capture import CaptureFixture from _pytest.capture import FDCapture from _pytest.capture import SysCapture @@ -355,9 +356,9 @@ def pytest_report_from_serializable(data: dict[str, Any]) -> SubtestReport | Non return None -# Dict of NodeId -> number of failed subtests. +# Dict of NodeId/OpaqueNodeId -> number of failed subtests. # Used to fail top-level tests that passed but contain failed subtests. -failed_subtests_key = StashKey[defaultdict[NodeId, int]]() +failed_subtests_key = StashKey[defaultdict[NodeId | OpaqueNodeId, int]]() def pytest_configure(config: Config) -> None: diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index fed06a10830..8ed7a99e187 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -39,6 +39,7 @@ from _pytest._io import TerminalWriter from _pytest._io.wcwidth import wcswidth from _pytest._nodeid import NodeId +from _pytest._nodeid import OpaqueNodeId import _pytest._version from _pytest.compat import running_on_ci from _pytest.config import _PluggyPlugin @@ -401,8 +402,8 @@ def __init__(self, config: Config, file: TextIO | None = None) -> None: # isatty should be a method but was wrongly implemented as a boolean. # We use CallableBool here to support both. self.isatty = compat.CallableBool(file.isatty()) - self._progress_nodeids_reported: set[NodeId] = set() - self._timing_nodeids_reported: set[NodeId] = set() + self._progress_nodeids_reported: set[NodeId | OpaqueNodeId] = set() + self._timing_nodeids_reported: set[NodeId | OpaqueNodeId] = set() self._show_progress_info = self._determine_show_progress_info() self._collect_report_last_write = timing.Instant() self._already_displayed_warnings: int | None = None @@ -1170,7 +1171,7 @@ def summary_passes_combined( self._outrep_summary(rep) self._handle_teardown_sections(rep.id) - def _get_teardown_reports(self, node_id: NodeId) -> list[TestReport]: + def _get_teardown_reports(self, node_id: NodeId | OpaqueNodeId) -> list[TestReport]: reports = self.getreports("") return [ report @@ -1178,7 +1179,7 @@ def _get_teardown_reports(self, node_id: NodeId) -> list[TestReport]: if report.when == "teardown" and report.id == node_id ] - def _handle_teardown_sections(self, node_id: NodeId) -> None: + def _handle_teardown_sections(self, node_id: NodeId | OpaqueNodeId) -> None: for report in self._get_teardown_reports(node_id): self.print_teardown_sections(report) diff --git a/testing/test_nodeid.py b/testing/test_nodeid.py index 15430137e1d..fec306999dd 100644 --- a/testing/test_nodeid.py +++ b/testing/test_nodeid.py @@ -1,7 +1,13 @@ from __future__ import annotations +from unittest import mock + +from _pytest._nodeid import coerce_node_id from _pytest._nodeid import NodeId +from _pytest._nodeid import OpaqueNodeId from _pytest._nodeid import ParamId +from _pytest.nodes import Node +from _pytest.reports import TestReport from _pytest.scope import Scope @@ -57,71 +63,119 @@ def test_eq_and_hash_with_params(self) -> None: assert hash(p1) == hash(p2) assert p1 != p3 - def test_eq_and_hash_across_construction_paths(self) -> None: + def test_eq_and_hash_across_types(self) -> None: """Equality/hash must be based on the canonical string form, not the raw fields -- a NodeId built from live collection data (rich - params, clean names) and one built from a plain string (degraded, - bracket glued into names) must compare equal and hash equal when - they represent the same logical node. This matters for e.g. - comparing a currently-collected item.id against a NodeId read back - from an on-disk cache file. + params, clean names) and an OpaqueNodeId built from a plain string + (opaque, unsplit rest) must compare equal and hash equal when they + represent the same logical node. This matters for e.g. comparing a + currently-collected item.id against a node id read back from an + on-disk cache file. """ via_child = NodeId(path="a/test_b.py").child( "test_c", (ParamId(id="1", argnames=("x",), scope=Scope.Function),) ) - via_string = NodeId.parse("a/test_b.py::test_c[1]") + via_string = OpaqueNodeId.parse("a/test_b.py::test_c[1]") assert str(via_child) == str(via_string) - assert via_child.params != via_string.params # structurally different... - assert via_child == via_string # ...but logically the same node. + assert via_child == via_string assert hash(via_child) == hash(via_string) - assert {via_child: "value"}[via_string] == "value" - - -class TestNodeIdCoerce: - def test_from_str(self) -> None: - node_id = NodeId.coerce("a/test_b.py::test_c") - assert node_id == NodeId(path="a/test_b.py", names=("test_c",)) - - def test_from_node_id_returns_same_object(self) -> None: - node_id = NodeId(path="a/test_b.py", names=("test_c",)) - assert NodeId.coerce(node_id) is node_id + by_node_id: dict[NodeId | OpaqueNodeId, str] = {via_child: "value"} + assert by_node_id[via_string] == "value" + + def test_string_construction_via_node_init(self) -> None: + """Node.__init__'s string branch (used e.g. by FSCollector for bare + paths) still supports a "::"-joined string for backward + compatibility, splitting it into clean names -- this never sees a + "[params]" bracket in practice (Function always pre-builds a real + NodeId instead), so producing a NodeId here is safe.""" + session = mock.Mock() + session.own_markers = [] + session.parent = None + session.nodeid = "" + session.id = NodeId(path="") + node = Node.from_parent( + session, name="ignored", nodeid="a/test_b.py::TestC::test_d" + ) + assert node.id == NodeId(path="a/test_b.py", names=("TestC", "test_d")) + assert node.nodeid == "a/test_b.py::TestC::test_d" -class TestNodeIdParse: +class TestOpaqueNodeId: def test_root(self) -> None: - node_id = NodeId.parse("") - assert node_id == NodeId(path="") + node_id = OpaqueNodeId.parse("") + assert node_id == OpaqueNodeId(path="") assert str(node_id) == "" def test_path_only(self) -> None: - node_id = NodeId.parse("a/b/test_c.py") - assert node_id == NodeId(path="a/b/test_c.py") - - def test_with_names(self) -> None: - node_id = NodeId.parse("a/test_b.py::TestC::test_d") - assert node_id == NodeId(path="a/test_b.py", names=("TestC", "test_d")) - - def test_bracket_stays_glued_and_params_empty(self) -> None: - """The [params] bracket cannot be reliably decomposed from a plain - string, so it stays glued verbatim to the last name and params is - always empty -- see NodeId's docstring.""" - node_id = NodeId.parse("a/test_b.py::test_c[1-x]") - assert node_id.names == ("test_c[1-x]",) - assert node_id.params == () + node_id = OpaqueNodeId.parse("a/b/test_c.py") + assert node_id == OpaqueNodeId(path="a/b/test_c.py", rest=None) + assert str(node_id) == "a/b/test_c.py" + + def test_with_rest(self) -> None: + node_id = OpaqueNodeId.parse("a/test_b.py::TestC::test_d") + assert node_id == OpaqueNodeId(path="a/test_b.py", rest="TestC::test_d") + + def test_rest_stays_opaque_including_brackets(self) -> None: + """The [params] bracket (and everything else past the first "::") + cannot be reliably decomposed from a plain string, so it stays + opaque, verbatim, in .rest -- see OpaqueNodeId's docstring.""" + node_id = OpaqueNodeId.parse("a/test_b.py::test_c[1-x]") + assert node_id.rest == "test_c[1-x]" + + def test_rest_none_vs_empty_string(self) -> None: + """None means no "::" was present at all, distinct from "" after a + trailing "::" -- both must round-trip losslessly via str.partition(). + """ + no_sep = OpaqueNodeId.parse("a/test_b.py") + trailing_sep = OpaqueNodeId.parse("a/test_b.py::") + assert no_sep.rest is None + assert trailing_sep.rest == "" + assert str(no_sep) == "a/test_b.py" + assert str(trailing_sep) == "a/test_b.py::" + assert no_sep != trailing_sep def test_round_trip_matches_original_string(self) -> None: for s in ( "", "a/test_b.py", + "a/test_b.py::", "a/test_b.py::TestC", "a/test_b.py::TestC::test_d", "a/test_b.py::test_c[1-x]", ): - assert str(NodeId.parse(s)) == s - - def test_equivalent_to_child_when_no_params(self) -> None: - """A NodeId built from a plain string and one built via .child() - with no params stringify identically for the same logical id.""" - via_string = NodeId.parse("a/test_b.py::test_c") - via_child = NodeId(path="a/test_b.py").child("test_c") - assert str(via_string) == str(via_child) + assert str(OpaqueNodeId.parse(s)) == s + + def test_eq_and_hash(self) -> None: + a = OpaqueNodeId.parse("a/test_b.py::test_c") + b = OpaqueNodeId.parse("a/test_b.py::test_c") + c = OpaqueNodeId.parse("a/test_b.py::test_d") + assert a == b + assert hash(a) == hash(b) + assert a != c + assert {a: 1}[b] == 1 + assert {a, b, c} == {a, c} + + +class TestCoerceNodeId: + def test_from_str(self) -> None: + node_id = coerce_node_id("a/test_b.py::test_c") + assert node_id == OpaqueNodeId(path="a/test_b.py", rest="test_c") + + def test_from_node_id_returns_same_object(self) -> None: + node_id = NodeId(path="a/test_b.py", names=("test_c",)) + assert coerce_node_id(node_id) is node_id + + +class TestWithNodeIdSetter: + def test_nodeid_setter_builds_opaque_node_id(self) -> None: + report = TestReport( + nodeid="a/test_b.py::test_c", + location=("a/test_b.py", 0, "test_c"), + keywords={}, + outcome="passed", + longrepr=None, + when="call", + ) + report.nodeid = "a/test_b.py::test_d" + assert report.id == OpaqueNodeId(path="a/test_b.py", rest="test_d") + assert report.nodeid == "a/test_b.py::test_d" From 415655ade4dede047e151724346ba1e54651583a Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 15:05:14 +0200 Subject: [PATCH 09/27] Fix docs build warnings from the NodeId/OpaqueNodeId refactor nitpick_ignore the intentionally-undocumented internal NodeId, OpaqueNodeId and _WithNodeId classes (same pattern already used for BaseReport etc.), and reword _WithNodeId.id's docstring to avoid an unresolvable :attr: cross-reference to `nodeid` in the TestReport/CollectReport doc context. Co-Authored-By: Claude Sonnet 5 --- doc/en/conf.py | 3 +++ src/_pytest/reports.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/doc/en/conf.py b/doc/en/conf.py index 04ba1cfc616..21ef1cbe201 100644 --- a/doc/en/conf.py +++ b/doc/en/conf.py @@ -89,6 +89,9 @@ ("py:class", "_pytest.python_api.RaisesContext"), ("py:class", "_pytest.recwarn.WarningsChecker"), ("py:class", "_pytest.reports.BaseReport"), + ("py:class", "_pytest.reports._WithNodeId"), + ("py:class", "_pytest._nodeid.NodeId"), + ("py:class", "_pytest._nodeid.OpaqueNodeId"), # Sphinx bugs(?) ("py:class", "RewriteHook"), # Undocumented third parties diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 834dd066143..661fe53aa65 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -333,7 +333,7 @@ def nodeid(self, value: str) -> None: @property def id(self) -> NodeId | OpaqueNodeId: - """The structured (non-string) form of :attr:`nodeid`. + """The structured (non-string) form of ``nodeid``. .. note:: From 22eb8089a80be56b81c4cc390ecdfc1f976898d1 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 16:37:39 +0200 Subject: [PATCH 10/27] Split NodeId into CollectionNodeId/ItemNodeId along the Collector/Item boundary PR review feedback (RonnyPfannschmidt) noted that NodeId.child() had no protection against being called on an id that already carries params -- i.e. building further tree structure on top of a parametrized leaf. Rather than a runtime assert, encode the distinction as a type: pytest's own Node hierarchy already splits cleanly into Collector (can have children) and Item (leaves), so map NodeId onto that exact boundary. - CollectionNodeId (path, names): has .child()/.leaf(), for Collector ids. - ItemNodeId (path, names, params): no .child()/.leaf() at all -- building further structure on a leaf is now a mypy error, not a runtime mistake. - NodeId = CollectionNodeId | ItemNodeId, a type alias for genuinely-mixed cases. Every container previously typed NodeId | OpaqueNodeId was re-derived from scratch by tracing what hook data actually flows into it (not carried over blindly): most narrow to ItemNodeId-only (NFPlugin.cached_nodeids, StepwiseCacheInfo.last_failed, terminal's per-item progress tracking, subtests' failed-count map), CollectReport/TestReport now carry CollectionNodeId/ItemNodeId respectively (with covariant Collector.id/ Item.id overrides to match), while LFPlugin.lastfailed, junitxml's node_reporters and terminal's timing tracker stay genuinely mixed since pytest_collectreport feeds collector-level failures into the same structures pytest_runtest_logreport feeds item-level ones into. Co-Authored-By: Claude Sonnet 5 --- changelog/14758.misc.rst | 2 +- doc/en/conf.py | 2 + src/_pytest/_nodeid.py | 188 ++++++++++++++++++++--------------- src/_pytest/cacheprovider.py | 7 +- src/_pytest/junitxml.py | 19 ++-- src/_pytest/nodes.py | 54 ++++++++-- src/_pytest/python.py | 8 +- src/_pytest/reports.py | 34 ++++++- src/_pytest/stepwise.py | 4 +- src/_pytest/subtests.py | 6 +- src/_pytest/terminal.py | 9 +- testing/python/metafunc.py | 6 +- testing/test_junitxml.py | 5 +- testing/test_mark.py | 6 +- testing/test_nodeid.py | 156 ++++++++++++++++++++--------- testing/test_nodes.py | 2 +- 16 files changed, 339 insertions(+), 169 deletions(-) diff --git a/changelog/14758.misc.rst b/changelog/14758.misc.rst index 59a64f1071b..28f4109264f 100644 --- a/changelog/14758.misc.rst +++ b/changelog/14758.misc.rst @@ -1 +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 structured dataclasses instead of being repeatedly re-parsed as plain strings: a ``NodeId`` built exclusively from live collection data, and a separate ``OpaqueNodeId`` for nodeids reconstructed from external sources (on-disk cache files, xdist's JSON wire format) whose parametrization details can't be reliably recovered. The public ``nodeid: str`` attribute on nodes and reports is unchanged and remains fully backward compatible for plugins. +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 structured dataclasses instead of being repeatedly re-parsed as plain strings: ``CollectionNodeId`` for collector nodes and ``ItemNodeId`` for leaf test items, both built exclusively from live collection data, plus a separate ``OpaqueNodeId`` for nodeids reconstructed from external sources (on-disk cache files, xdist's JSON wire format) whose parametrization details can't be reliably recovered. The public ``nodeid: str`` attribute on nodes and reports is unchanged and remains fully backward compatible for plugins. diff --git a/doc/en/conf.py b/doc/en/conf.py index 21ef1cbe201..28813641b0f 100644 --- a/doc/en/conf.py +++ b/doc/en/conf.py @@ -91,6 +91,8 @@ ("py:class", "_pytest.reports.BaseReport"), ("py:class", "_pytest.reports._WithNodeId"), ("py:class", "_pytest._nodeid.NodeId"), + ("py:class", "_pytest._nodeid.CollectionNodeId"), + ("py:class", "_pytest._nodeid.ItemNodeId"), ("py:class", "_pytest._nodeid.OpaqueNodeId"), # Sphinx bugs(?) ("py:class", "RewriteHook"), diff --git a/src/_pytest/_nodeid.py b/src/_pytest/_nodeid.py index f98306d9676..4870bb43e9b 100644 --- a/src/_pytest/_nodeid.py +++ b/src/_pytest/_nodeid.py @@ -2,18 +2,30 @@ A nodeid is a ``::``-separated string identifying a node in the collection tree, e.g. ``path/to/test_file.py::TestClass::test_method[param]``. -:class:`NodeId` is the structured, internal representation of this concept, -but it is only ever built from live collection data -- so that its -``params`` field can be trusted: a nodeid string's trailing ``[params]`` -bracket cannot be reliably decomposed back into individual -``parametrize()``-call boundaries once flattened (``"-"`` is used both to -join sub-ids within one call and to join separate stacked calls), so a -:class:`NodeId` built from an external string would either have to fabricate -that structure or silently lie about not having it. :class:`OpaqueNodeId` is -the honest alternative for that case: it only knows the ``path`` and an -unparsed ``rest``, and makes no claim to structured names or params. The -legacy ``::``-joined string form remains available (via ``str(node_id)``) -for backward compatibility with external plugins, for both types. + +There are three structured, internal representations of this concept, all +only ever built from live collection data or from a specific external +boundary -- so that their fields can always be trusted: + +- :class:`CollectionNodeId` -- for a ``Collector`` node (can still have + children). ``.child()``/``.leaf()`` build further ids on top of it. +- :class:`ItemNodeId` -- for an ``Item`` node (a leaf, e.g. a test + function). Carries ``params``; has no ``.child()``/``.leaf()`` at all, so + building further collection-tree structure on top of one is a static + type error, not just a runtime mistake. +- :data:`NodeId` -- a type alias, ``CollectionNodeId | ItemNodeId``, for + code that genuinely needs to accept/hold either kind. + +A nodeid string's trailing ``[params]`` bracket cannot be reliably +decomposed back into individual ``parametrize()``-call boundaries once +flattened (``"-"`` is used both to join sub-ids within one call and to join +separate stacked calls), so a :class:`ItemNodeId` built from an external +string would either have to fabricate that structure or silently lie about +not having it. :class:`OpaqueNodeId` is the honest alternative for that +case: it only knows the ``path`` and an unparsed ``rest``, and makes no +claim to structured names or params. The legacy ``::``-joined string form +remains available (via ``str(node_id)``) for backward compatibility with +external plugins, for all types. This module must stay a dependency-free leaf: it is imported from ``_pytest.nodes``, which is itself imported by ``_pytest.config``, so @@ -26,6 +38,7 @@ import dataclasses from typing import overload +from typing import TypeVar from _pytest.scope import Scope @@ -40,8 +53,8 @@ class ParamId: :attr:`_pytest.python.CallSpec2.param_ids`. ``argnames``/``scope`` are only known when built from live collection - data (see ``Function.__init__``) -- a :class:`NodeId` never has one of - these guessed from a string; see :class:`OpaqueNodeId` for the + data (see ``Function.__init__``) -- an :class:`ItemNodeId` never has one + of these guessed from a string; see :class:`OpaqueNodeId` for the string-boundary case, which has no ``ParamId``s at all. """ @@ -50,78 +63,110 @@ class ParamId: scope: Scope | None = None -@dataclasses.dataclass(frozen=True, eq=False) -class NodeId: - """Structured collection-tree address. +class _CachedStrEqHash: + """Shared ``str(self)`` caching plus cross-type equality/hashing for + :class:`CollectionNodeId`, :class:`ItemNodeId` and :class:`OpaqueNodeId`. + + Not a dataclass itself -- just method bodies reused by all three, since + they must compare/hash equal to each other by canonical string form + (e.g. a currently-collected ``item.id`` must compare equal to a + matching entry read back from an on-disk cache file), but each builds + its string differently. + """ - Only ever constructed from live collection data: either directly (for - trivial/root cases, e.g. the session root or a bare collector path) or - via :meth:`child`, chaining off an already-live parent's ``.id``. There - is deliberately no way to build one from an arbitrary string -- see the - module docstring and :class:`OpaqueNodeId`. + def _build_str(self) -> str: + raise NotImplementedError + + def __str__(self) -> str: + # Lazily compute and cache the string on first access -- it's used + # on every __eq__/__hash__ call, so it's worth not repeating the + # join/format work on every comparison. Not a dataclass field: just + # an ad hoc cached attribute. + cached: str | None = getattr(self, "_str", None) + if cached is not None: + return cached + base = self._build_str() + object.__setattr__(self, "_str", base) + return base + + def __eq__(self, other: object) -> bool: + if not isinstance(other, (CollectionNodeId, ItemNodeId, OpaqueNodeId)): + return NotImplemented + return str(self) == str(other) + + def __hash__(self) -> int: + return hash(str(self)) + + +@dataclasses.dataclass(frozen=True, eq=False) +class CollectionNodeId(_CachedStrEqHash): + """Structured address for a ``Collector`` node -- one that can still + have children built under it. :param path: ``/``-normalized, rootpath-relative filesystem path. Empty string for the session root. :param names: - Ordered ``::``-segment names. The *last* element may or may not - carry an embedded ``[params]`` suffix -- see :attr:`params`. - :param params: - Ordered per-``parametrize()``-call ids. + Ordered ``::``-segment names. + """ + + path: str + names: tuple[str, ...] = () + + def _build_str(self) -> str: + return "::".join((self.path, *self.names)) + + def child(self, name: str) -> CollectionNodeId: + """Return a new CollectionNodeId for a child collector node.""" + return CollectionNodeId(self.path, (*self.names, name)) - .. note:: + def leaf(self, name: str, params: tuple[ParamId, ...]) -> ItemNodeId: + """Return a new ItemNodeId for a terminal item node.""" + return ItemNodeId(self.path, (*self.names, name), params) - Equality and hashing are based on the canonical string form - (:meth:`__str__`), *not* on the raw ``path``/``names``/``params`` - fields, and compare equal across :class:`NodeId`/:class:`OpaqueNodeId` - -- e.g. a currently-collected ``item.id`` (``NodeId``) must compare - equal to a matching entry read back from an on-disk cache file - (``OpaqueNodeId``). Only the string form is a reliable, - source-independent identity for a node. + +@dataclasses.dataclass(frozen=True, eq=False) +class ItemNodeId(_CachedStrEqHash): + """Structured address for an ``Item`` node -- a leaf, e.g. a test + function. Has no ``.child()``/``.leaf()``: nothing ever builds further + collection-tree structure on top of an item id. + + :param path: + ``/``-normalized, rootpath-relative filesystem path. + :param names: + Ordered ``::``-segment names. + :param params: + Ordered per-``parametrize()``-call ids. """ path: str names: tuple[str, ...] = () params: tuple[ParamId, ...] = () - def __str__(self) -> str: - # Lazily compute and cache the joined string on first access -- it's - # used on every __eq__/__hash__ call (see the class docstring), so - # it's worth not repeating the join/format work on every comparison. - # Not a dataclass field: just an ad hoc cached attribute. - cached: str | None = getattr(self, "_str", None) - if cached is not None: - return cached + def _build_str(self) -> str: base = "::".join((self.path, *self.names)) if self.params: base += "[" + "-".join(p.id for p in self.params) + "]" - object.__setattr__(self, "_str", base) return base - def __eq__(self, other: object) -> bool: - if not isinstance(other, (NodeId, OpaqueNodeId)): - return NotImplemented - return str(self) == str(other) - def __hash__(self) -> int: - return hash(str(self)) - - def child(self, name: str, params: tuple[ParamId, ...] = ()) -> NodeId: - """Return a new NodeId for a child node with the given name.""" - return NodeId(self.path, (*self.names, name), params) +#: Either kind of live-collection node id, for code that genuinely needs to +#: accept/hold both a CollectionNodeId and an ItemNodeId. +NodeId = CollectionNodeId | ItemNodeId @dataclasses.dataclass(frozen=True, eq=False) -class OpaqueNodeId: +class OpaqueNodeId(_CachedStrEqHash): """A nodeid reconstructed from an external string source (an on-disk cache file, an xdist JSON wire payload, a duck-typed report-like object's ``.nodeid`` attribute, ...), rather than from live collection. - Unlike :class:`NodeId`, this makes no claim to structured names or - params: everything after the first ``"::"`` is left opaque and unsplit, - since it cannot be reliably decomposed (see the module docstring). There - is no ``.child()`` -- nothing ever builds further collection-tree - structure on top of a boundary-sourced id. + Unlike :class:`CollectionNodeId`/:class:`ItemNodeId`, this makes no + claim to structured names or params: everything after the first ``"::"`` + is left opaque and unsplit, since it cannot be reliably decomposed (see + the module docstring). There is no ``.child()``/``.leaf()`` -- nothing + ever builds further collection-tree structure on top of a + boundary-sourced id. """ path: str @@ -136,34 +181,21 @@ def parse(cls, nodeid: str) -> OpaqueNodeId: path, sep, rest = nodeid.partition("::") return cls(path, rest if sep else None) - def __str__(self) -> str: - # Same lazy-cache-on-first-access rationale as NodeId.__str__ -- - # these live in hot dict/set paths (--lf/--nf/--sw caches), hashed - # and compared once per collected item per run. - cached: str | None = getattr(self, "_str", None) - if cached is not None: - return cached - base = self.path if self.rest is None else f"{self.path}::{self.rest}" - object.__setattr__(self, "_str", base) - return base + def _build_str(self) -> str: + return self.path if self.rest is None else f"{self.path}::{self.rest}" - def __eq__(self, other: object) -> bool: - if not isinstance(other, (NodeId, OpaqueNodeId)): - return NotImplemented - return str(self) == str(other) - def __hash__(self) -> int: - return hash(str(self)) +_N = TypeVar("_N", bound=NodeId) @overload -def coerce_node_id(nodeid: NodeId) -> NodeId: ... +def coerce_node_id(nodeid: _N) -> _N: ... @overload def coerce_node_id(nodeid: str) -> OpaqueNodeId: ... def coerce_node_id(nodeid: str | NodeId) -> NodeId | OpaqueNodeId: - """Return ``nodeid`` unchanged if already a :class:`NodeId` (live + """Return ``nodeid`` unchanged if already a :data:`NodeId` (live collection data); otherwise treat it as an external nodeid string and wrap it in an :class:`OpaqueNodeId`.""" - if isinstance(nodeid, NodeId): + if isinstance(nodeid, (CollectionNodeId, ItemNodeId)): return nodeid return OpaqueNodeId.parse(nodeid) diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 31306c22e16..1ddd9054ad6 100644 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -21,6 +21,7 @@ from .reports import CollectReport from _pytest import nodes from _pytest._io import TerminalWriter +from _pytest._nodeid import ItemNodeId from _pytest._nodeid import NodeId from _pytest._nodeid import OpaqueNodeId from _pytest.config import Config @@ -434,7 +435,7 @@ def __init__(self, config: Config) -> None: self.config = config self.active = config.option.newfirst assert config.cache is not None - self.cached_nodeids: set[NodeId | OpaqueNodeId] = { + self.cached_nodeids: set[ItemNodeId | OpaqueNodeId] = { OpaqueNodeId.parse(s) for s in config.cache.get("cache/nodeids", []) } @@ -443,8 +444,8 @@ def pytest_collection_modifyitems(self, items: list[nodes.Item]) -> Generator[No res = yield if self.active: - new_items: dict[NodeId, nodes.Item] = {} - other_items: dict[NodeId, nodes.Item] = {} + new_items: dict[ItemNodeId, nodes.Item] = {} + other_items: dict[ItemNodeId, nodes.Item] = {} for item in items: if item.id not in self.cached_nodeids: new_items[item.id] = item diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index ede547ca2f4..1788a0ae360 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -30,6 +30,7 @@ from _pytest.fixtures import FixtureRequest from _pytest.reports import _WithNodeId from _pytest.reports import BaseReport +from _pytest.reports import CollectReport from _pytest.reports import TestReport from _pytest.stash import StashKey from _pytest.terminal import TerminalReporter @@ -116,19 +117,21 @@ def make_properties_node(self) -> ET.Element | None: return properties return None - def record_testreport(self, testreport: TestReport) -> None: + def record_testreport(self, testreport: TestReport | CollectReport) -> None: names = mangle_test_address(testreport.nodeid) existing_attrs = self.attrs classnames = names[:-1] if self.xml.prefix: classnames.insert(0, self.xml.prefix) + location = testreport.location + assert location is not None attrs: dict[str, str] = { "classname": ".".join(classnames), "name": bin_xml_escape(names[-1]), - "file": testreport.location[0], + "file": location[0], } - if testreport.location[1] is not None: - attrs["line"] = str(testreport.location[1]) + if location[1] is not None: + attrs["line"] = str(location[1]) if hasattr(testreport, "url"): attrs["url"] = testreport.url self.attrs = attrs @@ -209,12 +212,12 @@ def append_failure(self, report: TestReport) -> None: message = bin_xml_escape(message) self._add_simple("failure", message, str(report.longrepr)) - def append_collect_error(self, report: TestReport) -> None: + def append_collect_error(self, report: CollectReport) -> None: # msg = str(report.longrepr.reprtraceback.extraline) assert report.longrepr is not None self._add_simple("error", "collection failure", str(report.longrepr)) - def append_collect_skipped(self, report: TestReport) -> None: + def append_collect_skipped(self, report: CollectReport) -> None: self._add_simple("skipped", "collection skipped", str(report.longrepr)) def append_error(self, report: TestReport) -> None: @@ -536,7 +539,7 @@ def add_stats(self, key: str) -> None: if key in self.stats: self.stats[key] += 1 - def _opentestcase(self, report: TestReport) -> _NodeReporter: + def _opentestcase(self, report: TestReport | CollectReport) -> _NodeReporter: reporter = self.node_reporter(report) reporter.record_testreport(report) return reporter @@ -643,7 +646,7 @@ def update_testcase_duration(self, report: TestReport) -> None: reporter = self.node_reporter(report) reporter.duration += getattr(report, "duration", 0.0) - def pytest_collectreport(self, report: TestReport) -> None: + def pytest_collectreport(self, report: CollectReport) -> None: if not report.passed: reporter = self._opentestcase(report) if report.failed: diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index e0afc4e9630..42b48b9f8be 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -27,6 +27,8 @@ from _pytest._code.code import TerminalRepr from _pytest._code.code import Traceback from _pytest._code.code import TracebackStyle +from _pytest._nodeid import CollectionNodeId +from _pytest._nodeid import ItemNodeId from _pytest._nodeid import NodeId from _pytest.compat import LEGACY_PATH from _pytest.compat import signature @@ -199,17 +201,25 @@ def __init__( else: # Every real caller here passes either "" (session root) or # a bare collector path (never containing "::") -- Function, - # the only node type with real params/brackets, always - # pre-builds a full NodeId via parent.id.child(...) instead - # of reaching this branch. So this split can never see a - # "[params]" bracket, and .params legitimately staying () - # here is correct, not a lossy guess. + # the only Item with real params/brackets, always pre-builds + # a full ItemNodeId via parent.id.leaf(...) instead of + # reaching this branch. So this is always a Collector id. node_path, *names = nodeid.split("::") - self._id = NodeId(node_path, tuple(names)) + self._id = CollectionNodeId(node_path, tuple(names)) else: if not self.parent: raise TypeError("nodeid or parent must be provided") - self._id = self.parent.id.child(self.name) + # Node.parent is always structurally a Collector -- Items are + # always leaves and never have children. This assert is both a + # real runtime safety net and what lets mypy narrow + # self.parent.id to CollectionNodeId below. + assert isinstance(self.parent, Collector), ( + "Node.parent is always a Collector; an Item can never be a parent" + ) + if isinstance(self, Item): + self._id = self.parent.id.leaf(self.name, ()) + else: + self._id = self.parent.id.child(self.name) #: A place where plugins can store information on the node for their #: own use. @@ -517,6 +527,20 @@ class Collector(Node, abc.ABC): the collection tree. """ + _id: CollectionNodeId + + @property + def id(self) -> CollectionNodeId: + """The structured (non-string) form of ``nodeid``. + + .. note:: + + Experimental/internal: the shape of + :class:`~_pytest._nodeid.CollectionNodeId` may change in future + releases. + """ + return self._id + class CollectError(Exception): """An error during collection, contains a custom message.""" @@ -672,6 +696,20 @@ class Item(Node, abc.ABC): Note that for a single function there might be multiple test invocation items. """ + _id: ItemNodeId + + @property + def id(self) -> ItemNodeId: + """The structured (non-string) form of ``nodeid``. + + .. note:: + + Experimental/internal: the shape of + :class:`~_pytest._nodeid.ItemNodeId` may change in future + releases. + """ + return self._id + nextitem = None def __init__( @@ -680,7 +718,7 @@ def __init__( parent=None, config: Config | None = None, session: Session | None = None, - nodeid: str | NodeId | None = None, + nodeid: ItemNodeId | None = None, **kw, ) -> None: # The first two arguments are intentionally passed positionally, diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 63d629e274e..e4943428968 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1170,7 +1170,7 @@ class CallSpec: # Used for sorting parametrized resources. _arg2scope: Mapping[str, Scope] = dataclasses.field(default_factory=dict) # One entry per (possibly stacked) parametrize() call, in order. Joined - # with "-" they form the item's name `[..]` suffix; see NodeId.params. + # with "-" they form the item's name `[..]` suffix; see ItemNodeId.params. _idlist: Sequence[ParamId] = dataclasses.field(default_factory=tuple) # Marks which will be applied to the item. marks: list[Mark] = dataclasses.field(default_factory=list) @@ -1705,15 +1705,15 @@ def __init__( fixtureinfo: FuncFixtureInfo | None = None, originalname: str | None = None, ) -> None: - # Build the NodeId explicitly from callspec (when parametrized) + # Build the ItemNodeId explicitly from callspec (when parametrized) # instead of going through Node.__init__'s generic - # `parent.id.child(name)` fallback, which would only see `name` + # `parent.id.leaf(name, ())` fallback, which would only see `name` # (with any "[params]" suffix already glued on) and couldn't # recover the per-parametrize()-call structure captured in # callspec.param_ids. base_name = originalname or name params = callspec.param_ids if callspec is not None else () - node_id = parent.id.child(base_name, params) + node_id = parent.id.leaf(base_name, params) super().__init__(name, parent, config=config, session=session, nodeid=node_id) if callobj is not NOTSET: diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 661fe53aa65..e01b8ce625f 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -30,6 +30,8 @@ from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter from _pytest._nodeid import coerce_node_id +from _pytest._nodeid import CollectionNodeId +from _pytest._nodeid import ItemNodeId from _pytest._nodeid import NodeId from _pytest._nodeid import OpaqueNodeId from _pytest.config import Config @@ -356,9 +358,23 @@ class TestReport(_WithNodeId, BaseReport): # xfail reason if xfailed, otherwise not defined. Use hasattr to distinguish. wasxfail: str + _id: ItemNodeId | OpaqueNodeId + + @property + def id(self) -> ItemNodeId | OpaqueNodeId: + """The structured (non-string) form of ``nodeid``. + + .. note:: + + Experimental/internal: the shape of + :class:`~_pytest._nodeid.ItemNodeId` may change in future + releases. + """ + return self._id + def __init__( self, - nodeid: str | NodeId, + nodeid: str | ItemNodeId, location: tuple[str, int | None, str], keywords: Mapping[str, Any], outcome: Literal["passed", "failed", "skipped"], @@ -503,9 +519,23 @@ class CollectReport(_WithNodeId, BaseReport): when = "collect" + _id: CollectionNodeId | OpaqueNodeId + + @property + def id(self) -> CollectionNodeId | OpaqueNodeId: + """The structured (non-string) form of ``nodeid``. + + .. note:: + + Experimental/internal: the shape of + :class:`~_pytest._nodeid.CollectionNodeId` may change in future + releases. + """ + return self._id + def __init__( self, - nodeid: str | NodeId, + nodeid: str | CollectionNodeId, outcome: Literal["passed", "failed", "skipped"], longrepr: None | ExceptionInfo[BaseException] diff --git a/src/_pytest/stepwise.py b/src/_pytest/stepwise.py index 6398699a083..0c812414c75 100644 --- a/src/_pytest/stepwise.py +++ b/src/_pytest/stepwise.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING from _pytest import nodes -from _pytest._nodeid import NodeId +from _pytest._nodeid import ItemNodeId from _pytest._nodeid import OpaqueNodeId from _pytest.cacheprovider import Cache from _pytest.config import Config @@ -72,7 +72,7 @@ def pytest_sessionfinish(session: Session) -> None: @dataclasses.dataclass class StepwiseCacheInfo: # The nodeid of the last failed test. - last_failed: NodeId | OpaqueNodeId | None + last_failed: ItemNodeId | OpaqueNodeId | None # The number of tests in the last time --stepwise was run. # We use this information as a simple way to invalidate the cache information, avoiding diff --git a/src/_pytest/subtests.py b/src/_pytest/subtests.py index 960265d5c50..9997723dd1c 100644 --- a/src/_pytest/subtests.py +++ b/src/_pytest/subtests.py @@ -20,7 +20,7 @@ from _pytest._code import ExceptionInfo from _pytest._io.saferepr import saferepr -from _pytest._nodeid import NodeId +from _pytest._nodeid import ItemNodeId from _pytest._nodeid import OpaqueNodeId from _pytest.capture import CaptureFixture from _pytest.capture import FDCapture @@ -356,9 +356,9 @@ def pytest_report_from_serializable(data: dict[str, Any]) -> SubtestReport | Non return None -# Dict of NodeId/OpaqueNodeId -> number of failed subtests. +# Dict of ItemNodeId/OpaqueNodeId -> number of failed subtests. # Used to fail top-level tests that passed but contain failed subtests. -failed_subtests_key = StashKey[defaultdict[NodeId | OpaqueNodeId, int]]() +failed_subtests_key = StashKey[defaultdict[ItemNodeId | OpaqueNodeId, int]]() def pytest_configure(config: Config) -> None: diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 8ed7a99e187..7fe27ce0e9c 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -38,6 +38,7 @@ from _pytest._code.code import ExceptionRepr from _pytest._io import TerminalWriter from _pytest._io.wcwidth import wcswidth +from _pytest._nodeid import ItemNodeId from _pytest._nodeid import NodeId from _pytest._nodeid import OpaqueNodeId import _pytest._version @@ -402,7 +403,7 @@ def __init__(self, config: Config, file: TextIO | None = None) -> None: # isatty should be a method but was wrongly implemented as a boolean. # We use CallableBool here to support both. self.isatty = compat.CallableBool(file.isatty()) - self._progress_nodeids_reported: set[NodeId | OpaqueNodeId] = set() + self._progress_nodeids_reported: set[ItemNodeId | OpaqueNodeId] = set() self._timing_nodeids_reported: set[NodeId | OpaqueNodeId] = set() self._show_progress_info = self._determine_show_progress_info() self._collect_report_last_write = timing.Instant() @@ -1171,7 +1172,9 @@ def summary_passes_combined( self._outrep_summary(rep) self._handle_teardown_sections(rep.id) - def _get_teardown_reports(self, node_id: NodeId | OpaqueNodeId) -> list[TestReport]: + def _get_teardown_reports( + self, node_id: ItemNodeId | OpaqueNodeId + ) -> list[TestReport]: reports = self.getreports("") return [ report @@ -1179,7 +1182,7 @@ def _get_teardown_reports(self, node_id: NodeId | OpaqueNodeId) -> list[TestRepo if report.when == "teardown" and report.id == node_id ] - def _handle_teardown_sections(self, node_id: NodeId | OpaqueNodeId) -> None: + def _handle_teardown_sections(self, node_id: ItemNodeId | OpaqueNodeId) -> None: for report in self._get_teardown_reports(node_id): self.print_teardown_sections(report) diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 5327d2f1af8..45e243e38e0 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -17,7 +17,7 @@ from _pytest import fixtures from _pytest import python -from _pytest._nodeid import NodeId +from _pytest._nodeid import ItemNodeId from _pytest.compat import getfuncargnames from _pytest.compat import NOTSET from _pytest.outcomes import fail @@ -81,13 +81,13 @@ class SessionMock: @dataclasses.dataclass class DefinitionMock(python.FunctionDefinition): - _id: NodeId + _id: ItemNodeId obj: object names = getfuncargnames(func) fixtureinfo: Any = FuncFixtureInfoMock(names) definition: Any = DefinitionMock._create( - obj=func, _id=NodeId(path="mock", names=("nodeid",)) + obj=func, _id=ItemNodeId(path="mock", names=("nodeid",)) ) definition._fixtureinfo = fixtureinfo definition.session = SessionMock(config, FixtureManagerMock({})) diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index 1018b858413..5d4ddd49803 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -19,6 +19,7 @@ from _pytest.pytester import Pytester from _pytest.pytester import RunResult from _pytest.reports import BaseReport +from _pytest.reports import CollectReport from _pytest.reports import TestReport from _pytest.stash import Stash import _pytest.timing @@ -1258,8 +1259,8 @@ class Report(BaseReport): log.pytest_sessionstart() node_reporter = log._opentestcase(test_report) node_reporter.append_failure(test_report) - node_reporter.append_collect_error(test_report) - node_reporter.append_collect_skipped(test_report) + node_reporter.append_collect_error(cast(CollectReport, test_report)) + node_reporter.append_collect_skipped(cast(CollectReport, test_report)) node_reporter.append_error(test_report) test_report.longrepr = "filename", 1, ustr node_reporter.append_skipped(test_report) diff --git a/testing/test_mark.py b/testing/test_mark.py index 9ea002a3345..d35e50192b2 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -5,7 +5,7 @@ import sys from unittest import mock -from _pytest._nodeid import NodeId +from _pytest._nodeid import CollectionNodeId from _pytest.config import ExitCode from _pytest.mark import MarkGenerator from _pytest.mark.structures import EMPTY_PARAMETERSET_OPTION @@ -1132,11 +1132,11 @@ class TestBarClass(BaseTests): def test_addmarker_order(pytester) -> None: - session = mock.Mock() + session = mock.Mock(spec=Collector) session.own_markers = [] session.parent = None session.nodeid = "" - session.id = NodeId(path="") + session.id = CollectionNodeId(path="") session.path = pytester.path node = Node.from_parent(session, name="Test") node.add_marker("foo") diff --git a/testing/test_nodeid.py b/testing/test_nodeid.py index fec306999dd..9dda6063ee9 100644 --- a/testing/test_nodeid.py +++ b/testing/test_nodeid.py @@ -3,7 +3,8 @@ from unittest import mock from _pytest._nodeid import coerce_node_id -from _pytest._nodeid import NodeId +from _pytest._nodeid import CollectionNodeId +from _pytest._nodeid import ItemNodeId from _pytest._nodeid import OpaqueNodeId from _pytest._nodeid import ParamId from _pytest.nodes import Node @@ -11,92 +12,141 @@ from _pytest.scope import Scope -class TestNodeId: +class TestParamId: + def test_eq_and_hash(self) -> None: + p1 = ParamId(id="1", argnames=("x",), scope=Scope.Function) + p2 = ParamId(id="1", argnames=("x",), scope=Scope.Function) + p3 = ParamId(id="1", argnames=("y",), scope=Scope.Function) + assert p1 == p2 + assert hash(p1) == hash(p2) + assert p1 != p3 + + +class TestCollectionNodeId: def test_str_root(self) -> None: - assert str(NodeId(path="")) == "" + assert str(CollectionNodeId(path="")) == "" def test_str_path_only(self) -> None: - assert str(NodeId(path="a/b/test_c.py")) == "a/b/test_c.py" + assert str(CollectionNodeId(path="a/b/test_c.py")) == "a/b/test_c.py" def test_str_with_names(self) -> None: - node_id = NodeId(path="a/test_b.py", names=("TestC", "test_d")) + node_id = CollectionNodeId(path="a/test_b.py", names=("TestC", "test_d")) assert str(node_id) == "a/test_b.py::TestC::test_d" + def test_child(self) -> None: + parent = CollectionNodeId(path="a/test_b.py") + child = parent.child("TestC") + assert child == CollectionNodeId(path="a/test_b.py", names=("TestC",)) + grandchild = child.child("test_d") + assert grandchild == CollectionNodeId( + path="a/test_b.py", names=("TestC", "test_d") + ) + + def test_leaf(self) -> None: + parent = CollectionNodeId(path="a/test_b.py") + params = (ParamId(id="1", argnames=("x",), scope=Scope.Function),) + leaf = parent.leaf("test_c", params) + assert isinstance(leaf, ItemNodeId) + assert leaf.params == params + assert str(leaf) == "a/test_b.py::test_c[1]" + + def test_eq_and_hash(self) -> None: + a = CollectionNodeId(path="a/test_b.py", names=("TestC",)) + b = CollectionNodeId(path="a/test_b.py", names=("TestC",)) + c = CollectionNodeId(path="a/test_b.py", names=("TestD",)) + assert a == b + assert hash(a) == hash(b) + assert a != c + # Usable as dict keys / set members. + assert {a: 1}[b] == 1 + assert {a, b, c} == {a, c} + + +class TestItemNodeId: + def test_str_no_params(self) -> None: + node_id = ItemNodeId(path="a/test_b.py", names=("test_c",)) + assert str(node_id) == "a/test_b.py::test_c" + def test_str_with_params(self) -> None: - node_id = NodeId( + node_id = ItemNodeId( path="a/test_b.py", names=("test_c",), params=(ParamId(id="1"), ParamId(id="x")), ) assert str(node_id) == "a/test_b.py::test_c[1-x]" - def test_child(self) -> None: - parent = NodeId(path="a/test_b.py") - child = parent.child("TestC") - assert child == NodeId(path="a/test_b.py", names=("TestC",)) - grandchild = child.child("test_d") - assert grandchild == NodeId(path="a/test_b.py", names=("TestC", "test_d")) - - def test_child_with_params(self) -> None: - parent = NodeId(path="a/test_b.py") - params = (ParamId(id="1", argnames=("x",), scope=Scope.Function),) - child = parent.child("test_c", params) - assert child.params == params - assert str(child) == "a/test_b.py::test_c[1]" + def test_has_no_child_or_leaf(self) -> None: + """Nothing ever builds further collection-tree structure on top of + an item id -- calling .child()/.leaf() is a static AttributeError, + not just a runtime mistake.""" + node_id = ItemNodeId(path="a/test_b.py", names=("test_c",)) + assert not hasattr(node_id, "child") + assert not hasattr(node_id, "leaf") def test_eq_and_hash(self) -> None: - a = NodeId(path="a/test_b.py", names=("test_c",)) - b = NodeId(path="a/test_b.py", names=("test_c",)) - c = NodeId(path="a/test_b.py", names=("test_d",)) + a = ItemNodeId(path="a/test_b.py", names=("test_c",)) + b = ItemNodeId(path="a/test_b.py", names=("test_c",)) + c = ItemNodeId(path="a/test_b.py", names=("test_d",)) assert a == b assert hash(a) == hash(b) assert a != c - # Usable as dict keys / set members. assert {a: 1}[b] == 1 assert {a, b, c} == {a, c} - def test_eq_and_hash_with_params(self) -> None: - p1 = ParamId(id="1", argnames=("x",), scope=Scope.Function) - p2 = ParamId(id="1", argnames=("x",), scope=Scope.Function) - p3 = ParamId(id="1", argnames=("y",), scope=Scope.Function) - assert p1 == p2 - assert hash(p1) == hash(p2) - assert p1 != p3 - def test_eq_and_hash_across_types(self) -> None: +class TestCrossTypeEquality: + def test_eq_and_hash_across_all_three_types(self) -> None: """Equality/hash must be based on the canonical string form, not the - raw fields -- a NodeId built from live collection data (rich - params, clean names) and an OpaqueNodeId built from a plain string - (opaque, unsplit rest) must compare equal and hash equal when they - represent the same logical node. This matters for e.g. comparing a + raw fields or concrete class -- a CollectionNodeId/ItemNodeId built + from live collection data and an OpaqueNodeId built from a plain + string must all compare equal and hash equal when they represent + the same logical node. This matters for e.g. comparing a currently-collected item.id against a node id read back from an - on-disk cache file. + on-disk cache file, and for containers that deliberately mix live + and cache-sourced ids (e.g. cacheprovider's lastfailed). """ - via_child = NodeId(path="a/test_b.py").child( + collection = CollectionNodeId(path="a/test_b.py", names=("test_c",)) + item = CollectionNodeId(path="a/test_b.py").leaf( "test_c", (ParamId(id="1", argnames=("x",), scope=Scope.Function),) ) - via_string = OpaqueNodeId.parse("a/test_b.py::test_c[1]") - assert str(via_child) == str(via_string) - assert via_child == via_string - assert hash(via_child) == hash(via_string) - by_node_id: dict[NodeId | OpaqueNodeId, str] = {via_child: "value"} - assert by_node_id[via_string] == "value" + opaque_collection = OpaqueNodeId.parse("a/test_b.py::test_c") + opaque_item = OpaqueNodeId.parse("a/test_b.py::test_c[1]") + + assert str(collection) == str(opaque_collection) + assert collection == opaque_collection + assert hash(collection) == hash(opaque_collection) + + assert str(item) == str(opaque_item) + assert item == opaque_item + assert hash(item) == hash(opaque_item) + + # Different concrete classes must not spuriously compare equal just + # because they're both "some kind of NodeId". + assert collection != item + assert collection != opaque_item + assert item != opaque_collection + + by_node_id: dict[object, str] = {item: "value"} + assert by_node_id[opaque_item] == "value" def test_string_construction_via_node_init(self) -> None: """Node.__init__'s string branch (used e.g. by FSCollector for bare paths) still supports a "::"-joined string for backward compatibility, splitting it into clean names -- this never sees a "[params]" bracket in practice (Function always pre-builds a real - NodeId instead), so producing a NodeId here is safe.""" + ItemNodeId instead), so producing a CollectionNodeId here is + safe.""" session = mock.Mock() session.own_markers = [] session.parent = None session.nodeid = "" - session.id = NodeId(path="") + session.id = CollectionNodeId(path="") node = Node.from_parent( session, name="ignored", nodeid="a/test_b.py::TestC::test_d" ) - assert node.id == NodeId(path="a/test_b.py", names=("TestC", "test_d")) + assert node.id == CollectionNodeId( + path="a/test_b.py", names=("TestC", "test_d") + ) assert node.nodeid == "a/test_b.py::TestC::test_d" @@ -161,10 +211,20 @@ def test_from_str(self) -> None: node_id = coerce_node_id("a/test_b.py::test_c") assert node_id == OpaqueNodeId(path="a/test_b.py", rest="test_c") - def test_from_node_id_returns_same_object(self) -> None: - node_id = NodeId(path="a/test_b.py", names=("test_c",)) + def test_from_collection_node_id_returns_same_object(self) -> None: + node_id = CollectionNodeId(path="a/test_b.py", names=("test_c",)) assert coerce_node_id(node_id) is node_id + def test_from_item_node_id_returns_same_object_and_type(self) -> None: + """Regression test: an earlier plain `NodeId -> NodeId` overload + widened the return type to the full union, erasing which concrete + subtype was passed in -- coerce_node_id must echo back the exact + concrete type given.""" + node_id = ItemNodeId(path="a/test_b.py", names=("test_c",)) + result = coerce_node_id(node_id) + assert result is node_id + assert isinstance(result, ItemNodeId) + class TestWithNodeIdSetter: def test_nodeid_setter_builds_opaque_node_id(self) -> None: diff --git a/testing/test_nodes.py b/testing/test_nodes.py index e976b9e6f11..775e28d66e6 100644 --- a/testing/test_nodes.py +++ b/testing/test_nodes.py @@ -40,7 +40,7 @@ def test_subclassing_both_item_and_collector_deprecated( with warnings.catch_warnings(): warnings.simplefilter("error") - class SoWrong(nodes.Item, nodes.File): + class SoWrong(nodes.Item, nodes.File): # type: ignore[misc] def __init__(self, parent: nodes.Collector, path: Path) -> None: super().__init__(name="broken", parent=parent, path=path) From a1485b4709c4b68174f60cf2790fef5f4f03a98d Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 16:44:32 +0200 Subject: [PATCH 11/27] Cache the original string directly in OpaqueNodeId.parse() Addresses PR review feedback: since .parse() already has the full nodeid string in hand, cache it directly as _str instead of splitting into path/rest and letting __str__ reconstruct it later. Direct construction (bypassing .parse()) still reconstructs via _build_str() as before. Co-Authored-By: Claude Sonnet 5 --- src/_pytest/_nodeid.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/_pytest/_nodeid.py b/src/_pytest/_nodeid.py index 4870bb43e9b..fcee4118d09 100644 --- a/src/_pytest/_nodeid.py +++ b/src/_pytest/_nodeid.py @@ -179,7 +179,11 @@ class OpaqueNodeId(_CachedStrEqHash): def parse(cls, nodeid: str) -> OpaqueNodeId: """Split a nodeid string into its path and an opaque remainder.""" path, sep, rest = nodeid.partition("::") - return cls(path, rest if sep else None) + self = cls(path, rest if sep else None) + # We already have the original string in hand -- cache it directly + # as _str instead of letting __str__ reconstruct it later. + object.__setattr__(self, "_str", nodeid) + return self def _build_str(self) -> str: return self.path if self.rest is None else f"{self.path}::{self.rest}" From f5bf42d5f4a224c07f5670614e58a56302da58ea Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 16:56:13 +0200 Subject: [PATCH 12/27] Add NodeId.to_opaque() and use OpaqueNodeId as LFPlugin/NFPlugin's sole lookup type Addresses PR review feedback: cacheprovider.py's lastfailed/cached_nodeids don't care about the rich structure CollectionNodeId/ItemNodeId carry, only about identity -- normalizing everything to OpaqueNodeId avoids mixing lookup types in the same container. - CollectionNodeId/ItemNodeId get a trivial .to_opaque() -> OpaqueNodeId method. - New to_opaque_node_id() free function normalizes a NodeId | OpaqueNodeId value (e.g. a report's .id, which may be live or reconstructed from JSON) down to OpaqueNodeId unconditionally. - LFPlugin.lastfailed: dict[OpaqueNodeId, bool], NFPlugin.cached_nodeids: set[OpaqueNodeId] -- both narrowed from the NodeId | OpaqueNodeId / ItemNodeId | OpaqueNodeId unions, with .to_opaque()/to_opaque_node_id() conversions at every insertion/lookup site. Co-Authored-By: Claude Sonnet 5 --- src/_pytest/_nodeid.py | 23 +++++++++++++++++++++++ src/_pytest/cacheprovider.py | 33 ++++++++++++++++++--------------- testing/test_nodeid.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 15 deletions(-) diff --git a/src/_pytest/_nodeid.py b/src/_pytest/_nodeid.py index fcee4118d09..37625271dd9 100644 --- a/src/_pytest/_nodeid.py +++ b/src/_pytest/_nodeid.py @@ -124,6 +124,12 @@ def leaf(self, name: str, params: tuple[ParamId, ...]) -> ItemNodeId: """Return a new ItemNodeId for a terminal item node.""" return ItemNodeId(self.path, (*self.names, name), params) + def to_opaque(self) -> OpaqueNodeId: + """Return the OpaqueNodeId form of this id, for code that only ever + needs a single, non-structured lookup type (e.g. cache boundaries + that mix live and cache-sourced ids).""" + return OpaqueNodeId.parse(str(self)) + @dataclasses.dataclass(frozen=True, eq=False) class ItemNodeId(_CachedStrEqHash): @@ -149,6 +155,12 @@ def _build_str(self) -> str: base += "[" + "-".join(p.id for p in self.params) + "]" return base + def to_opaque(self) -> OpaqueNodeId: + """Return the OpaqueNodeId form of this id, for code that only ever + needs a single, non-structured lookup type (e.g. cache boundaries + that mix live and cache-sourced ids).""" + return OpaqueNodeId.parse(str(self)) + #: Either kind of live-collection node id, for code that genuinely needs to #: accept/hold both a CollectionNodeId and an ItemNodeId. @@ -203,3 +215,14 @@ def coerce_node_id(nodeid: str | NodeId) -> NodeId | OpaqueNodeId: if isinstance(nodeid, (CollectionNodeId, ItemNodeId)): return nodeid return OpaqueNodeId.parse(nodeid) + + +def to_opaque_node_id(node_id: NodeId | OpaqueNodeId) -> OpaqueNodeId: + """Return ``node_id`` unchanged if already an :class:`OpaqueNodeId`, + otherwise convert it via :meth:`~CollectionNodeId.to_opaque`. Useful for + normalizing a mixed ``NodeId | OpaqueNodeId`` value (e.g. a report's + ``.id``, which may be live or reconstructed from JSON) down to a single + lookup type.""" + if isinstance(node_id, OpaqueNodeId): + return node_id + return node_id.to_opaque() diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 1ddd9054ad6..4d2a6c59dee 100644 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -22,8 +22,8 @@ from _pytest import nodes from _pytest._io import TerminalWriter from _pytest._nodeid import ItemNodeId -from _pytest._nodeid import NodeId from _pytest._nodeid import OpaqueNodeId +from _pytest._nodeid import to_opaque_node_id from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import hookimpl @@ -274,7 +274,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.id in lastfailed for x in result): + if not any(x.id.to_opaque() in lastfailed for x in result): return res self.lfplugin.config.pluginmanager.register( LFPluginCollSkipfiles(self.lfplugin), "lfplugin-collskip" @@ -285,7 +285,7 @@ def sort_key(node: nodes.Item | nodes.Collector) -> bool: result[:] = [ x for x in result - if x.id in lastfailed + if x.id.to_opaque() in lastfailed # Include any passed arguments (not trivial to filter). or session.isinitpath(x.path) # Keep all sub-collectors. @@ -319,7 +319,7 @@ 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[NodeId | OpaqueNodeId, bool] = { + self.lastfailed: dict[OpaqueNodeId, bool] = { OpaqueNodeId.parse(k): v for k, v in config.cache.get("cache/lastfailed", {}).items() } @@ -351,18 +351,21 @@ 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.id, None) + self.lastfailed.pop(to_opaque_node_id(report.id), None) elif report.failed: - self.lastfailed[report.id] = True + self.lastfailed[to_opaque_node_id(report.id)] = True def pytest_collectreport(self, report: CollectReport) -> None: passed = report.outcome in ("passed", "skipped") if passed: - if report.id in self.lastfailed: - self.lastfailed.pop(report.id) - self.lastfailed.update((item.id, True) for item in report.result) + report_id = to_opaque_node_id(report.id) + if report_id in self.lastfailed: + self.lastfailed.pop(report_id) + self.lastfailed.update( + (item.id.to_opaque(), True) for item in report.result + ) else: - self.lastfailed[report.id] = True + self.lastfailed[to_opaque_node_id(report.id)] = True @hookimpl(wrapper=True, tryfirst=True) def pytest_collection_modifyitems( @@ -377,7 +380,7 @@ def pytest_collection_modifyitems( previously_failed = [] previously_passed = [] for item in items: - if item.id in self.lastfailed: + if item.id.to_opaque() in self.lastfailed: previously_failed.append(item) else: previously_passed.append(item) @@ -435,7 +438,7 @@ def __init__(self, config: Config) -> None: self.config = config self.active = config.option.newfirst assert config.cache is not None - self.cached_nodeids: set[ItemNodeId | OpaqueNodeId] = { + self.cached_nodeids: set[OpaqueNodeId] = { OpaqueNodeId.parse(s) for s in config.cache.get("cache/nodeids", []) } @@ -447,7 +450,7 @@ def pytest_collection_modifyitems(self, items: list[nodes.Item]) -> Generator[No new_items: dict[ItemNodeId, nodes.Item] = {} other_items: dict[ItemNodeId, nodes.Item] = {} for item in items: - if item.id not in self.cached_nodeids: + if item.id.to_opaque() not in self.cached_nodeids: new_items[item.id] = item else: other_items[item.id] = item @@ -455,9 +458,9 @@ def pytest_collection_modifyitems(self, items: list[nodes.Item]) -> Generator[No items[:] = self._get_increasing_order( new_items.values() ) + self._get_increasing_order(other_items.values()) - self.cached_nodeids.update(new_items) + self.cached_nodeids.update(k.to_opaque() for k in new_items) else: - self.cached_nodeids.update(item.id for item in items) + self.cached_nodeids.update(item.id.to_opaque() for item in items) return res diff --git a/testing/test_nodeid.py b/testing/test_nodeid.py index 9dda6063ee9..ee06072720e 100644 --- a/testing/test_nodeid.py +++ b/testing/test_nodeid.py @@ -7,6 +7,7 @@ from _pytest._nodeid import ItemNodeId from _pytest._nodeid import OpaqueNodeId from _pytest._nodeid import ParamId +from _pytest._nodeid import to_opaque_node_id from _pytest.nodes import Node from _pytest.reports import TestReport from _pytest.scope import Scope @@ -61,6 +62,13 @@ def test_eq_and_hash(self) -> None: assert {a: 1}[b] == 1 assert {a, b, c} == {a, c} + def test_to_opaque(self) -> None: + node_id = CollectionNodeId(path="a/test_b.py", names=("TestC",)) + opaque = node_id.to_opaque() + assert isinstance(opaque, OpaqueNodeId) + assert opaque == node_id + assert str(opaque) == str(node_id) + class TestItemNodeId: def test_str_no_params(self) -> None: @@ -93,6 +101,15 @@ def test_eq_and_hash(self) -> None: assert {a: 1}[b] == 1 assert {a, b, c} == {a, c} + def test_to_opaque(self) -> None: + node_id = ItemNodeId( + path="a/test_b.py", names=("test_c",), params=(ParamId(id="1"),) + ) + opaque = node_id.to_opaque() + assert isinstance(opaque, OpaqueNodeId) + assert opaque == node_id + assert str(opaque) == str(node_id) + class TestCrossTypeEquality: def test_eq_and_hash_across_all_three_types(self) -> None: @@ -226,6 +243,24 @@ def test_from_item_node_id_returns_same_object_and_type(self) -> None: assert isinstance(result, ItemNodeId) +class TestToOpaqueNodeId: + def test_from_collection_node_id(self) -> None: + node_id = CollectionNodeId(path="a/test_b.py", names=("TestC",)) + opaque = to_opaque_node_id(node_id) + assert isinstance(opaque, OpaqueNodeId) + assert opaque == node_id + + def test_from_item_node_id(self) -> None: + node_id = ItemNodeId(path="a/test_b.py", names=("test_c",)) + opaque = to_opaque_node_id(node_id) + assert isinstance(opaque, OpaqueNodeId) + assert opaque == node_id + + def test_from_opaque_node_id_returns_same_object(self) -> None: + node_id = OpaqueNodeId.parse("a/test_b.py::test_c") + assert to_opaque_node_id(node_id) is node_id + + class TestWithNodeIdSetter: def test_nodeid_setter_builds_opaque_node_id(self) -> None: report = TestReport( From a68e9066642e361e921d2c4da46ef67c88e0c84b Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 17:51:05 +0200 Subject: [PATCH 13/27] Add @override to _build_str implementations, moving the shim to compat.py typing.override was added in Python 3.12; pytest supports >=3.10, so add a version-gated compat shim (mirroring the existing `deprecated` pattern in _pytest.compat) rather than duplicating it in _nodeid.py, which stays a leaf module since compat.py has no _pytest.* imports of its own either. Co-Authored-By: Claude Sonnet 5 --- src/_pytest/_nodeid.py | 9 +++++++-- src/_pytest/compat.py | 11 +++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/_pytest/_nodeid.py b/src/_pytest/_nodeid.py index 37625271dd9..1e8adefc5c3 100644 --- a/src/_pytest/_nodeid.py +++ b/src/_pytest/_nodeid.py @@ -30,8 +30,9 @@ This module must stay a dependency-free leaf: it is imported from ``_pytest.nodes``, which is itself imported by ``_pytest.config``, so importing anything from ``_pytest.config``/``_pytest.nodes`` here would -create a cycle. Importing :class:`~_pytest.scope.Scope` is safe, since -``_pytest.scope`` is itself documented as a dependency-free leaf module. +create a cycle. Importing :class:`~_pytest.scope.Scope` and +``_pytest.compat`` is safe, since both are themselves dependency-free leaf +modules. """ from __future__ import annotations @@ -40,6 +41,7 @@ from typing import overload from typing import TypeVar +from _pytest.compat import override from _pytest.scope import Scope @@ -113,6 +115,7 @@ class CollectionNodeId(_CachedStrEqHash): path: str names: tuple[str, ...] = () + @override def _build_str(self) -> str: return "::".join((self.path, *self.names)) @@ -149,6 +152,7 @@ class ItemNodeId(_CachedStrEqHash): names: tuple[str, ...] = () params: tuple[ParamId, ...] = () + @override def _build_str(self) -> str: base = "::".join((self.path, *self.names)) if self.params: @@ -197,6 +201,7 @@ def parse(cls, nodeid: str) -> OpaqueNodeId: object.__setattr__(self, "_str", nodeid) return self + @override def _build_str(self) -> str: return self.path if self.rest is None else f"{self.path}::{self.rest}" diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index d3b2a469693..4f0a8eca133 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -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 From dae32787c6adc6e595f3528ab1f1605167e47a01 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 23 Jul 2026 10:30:37 +0200 Subject: [PATCH 14/27] Rename NodeId.to_opaque to as_opaque, add OpaqueNodeId.as_opaque OpaqueNodeId.as_opaque() returns self, letting callers holding a NodeId | OpaqueNodeId value call .as_opaque() unconditionally without checking which concrete type they have. This replaces the standalone to_opaque_node_id() free function, which is no longer needed. Co-Authored-By: Claude Sonnet 5 --- src/_pytest/_nodeid.py | 31 ++++++++++++++++++------------- src/_pytest/cacheprovider.py | 23 +++++++++++------------ testing/test_nodeid.py | 30 ++++++++++-------------------- 3 files changed, 39 insertions(+), 45 deletions(-) diff --git a/src/_pytest/_nodeid.py b/src/_pytest/_nodeid.py index 1e8adefc5c3..3ecf2870972 100644 --- a/src/_pytest/_nodeid.py +++ b/src/_pytest/_nodeid.py @@ -39,12 +39,17 @@ import dataclasses from typing import overload +from typing import TYPE_CHECKING from typing import TypeVar from _pytest.compat import override from _pytest.scope import Scope +if TYPE_CHECKING: + from typing_extensions import Self + + @dataclasses.dataclass(frozen=True) class ParamId: """One resolved id contributed by a single (possibly stacked) @@ -127,7 +132,7 @@ def leaf(self, name: str, params: tuple[ParamId, ...]) -> ItemNodeId: """Return a new ItemNodeId for a terminal item node.""" return ItemNodeId(self.path, (*self.names, name), params) - def to_opaque(self) -> OpaqueNodeId: + def as_opaque(self) -> OpaqueNodeId: """Return the OpaqueNodeId form of this id, for code that only ever needs a single, non-structured lookup type (e.g. cache boundaries that mix live and cache-sourced ids).""" @@ -159,7 +164,7 @@ def _build_str(self) -> str: base += "[" + "-".join(p.id for p in self.params) + "]" return base - def to_opaque(self) -> OpaqueNodeId: + def as_opaque(self) -> OpaqueNodeId: """Return the OpaqueNodeId form of this id, for code that only ever needs a single, non-structured lookup type (e.g. cache boundaries that mix live and cache-sourced ids).""" @@ -205,6 +210,17 @@ def parse(cls, nodeid: str) -> OpaqueNodeId: def _build_str(self) -> str: return self.path if self.rest is None else f"{self.path}::{self.rest}" + def as_opaque(self) -> Self: + """Return self. + + Only added for consistency and ease of use with + :meth:`CollectionNodeId.as_opaque`/:meth:`ItemNodeId.as_opaque`, so + callers holding a ``NodeId | OpaqueNodeId`` value can call + ``.as_opaque()`` unconditionally without checking which kind they + have. + """ + return self + _N = TypeVar("_N", bound=NodeId) @@ -220,14 +236,3 @@ def coerce_node_id(nodeid: str | NodeId) -> NodeId | OpaqueNodeId: if isinstance(nodeid, (CollectionNodeId, ItemNodeId)): return nodeid return OpaqueNodeId.parse(nodeid) - - -def to_opaque_node_id(node_id: NodeId | OpaqueNodeId) -> OpaqueNodeId: - """Return ``node_id`` unchanged if already an :class:`OpaqueNodeId`, - otherwise convert it via :meth:`~CollectionNodeId.to_opaque`. Useful for - normalizing a mixed ``NodeId | OpaqueNodeId`` value (e.g. a report's - ``.id``, which may be live or reconstructed from JSON) down to a single - lookup type.""" - if isinstance(node_id, OpaqueNodeId): - return node_id - return node_id.to_opaque() diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 4d2a6c59dee..d94e632526b 100644 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -23,7 +23,6 @@ from _pytest._io import TerminalWriter from _pytest._nodeid import ItemNodeId from _pytest._nodeid import OpaqueNodeId -from _pytest._nodeid import to_opaque_node_id from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import hookimpl @@ -274,7 +273,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.id.to_opaque() in lastfailed for x in result): + if not any(x.id.as_opaque() in lastfailed for x in result): return res self.lfplugin.config.pluginmanager.register( LFPluginCollSkipfiles(self.lfplugin), "lfplugin-collskip" @@ -285,7 +284,7 @@ def sort_key(node: nodes.Item | nodes.Collector) -> bool: result[:] = [ x for x in result - if x.id.to_opaque() in lastfailed + if x.id.as_opaque() in lastfailed # Include any passed arguments (not trivial to filter). or session.isinitpath(x.path) # Keep all sub-collectors. @@ -351,21 +350,21 @@ 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(to_opaque_node_id(report.id), None) + self.lastfailed.pop(report.id.as_opaque(), None) elif report.failed: - self.lastfailed[to_opaque_node_id(report.id)] = True + self.lastfailed[report.id.as_opaque()] = True def pytest_collectreport(self, report: CollectReport) -> None: passed = report.outcome in ("passed", "skipped") if passed: - report_id = to_opaque_node_id(report.id) + report_id = report.id.as_opaque() if report_id in self.lastfailed: self.lastfailed.pop(report_id) self.lastfailed.update( - (item.id.to_opaque(), True) for item in report.result + (item.id.as_opaque(), True) for item in report.result ) else: - self.lastfailed[to_opaque_node_id(report.id)] = True + self.lastfailed[report.id.as_opaque()] = True @hookimpl(wrapper=True, tryfirst=True) def pytest_collection_modifyitems( @@ -380,7 +379,7 @@ def pytest_collection_modifyitems( previously_failed = [] previously_passed = [] for item in items: - if item.id.to_opaque() in self.lastfailed: + if item.id.as_opaque() in self.lastfailed: previously_failed.append(item) else: previously_passed.append(item) @@ -450,7 +449,7 @@ def pytest_collection_modifyitems(self, items: list[nodes.Item]) -> Generator[No new_items: dict[ItemNodeId, nodes.Item] = {} other_items: dict[ItemNodeId, nodes.Item] = {} for item in items: - if item.id.to_opaque() not in self.cached_nodeids: + if item.id.as_opaque() not in self.cached_nodeids: new_items[item.id] = item else: other_items[item.id] = item @@ -458,9 +457,9 @@ def pytest_collection_modifyitems(self, items: list[nodes.Item]) -> Generator[No items[:] = self._get_increasing_order( new_items.values() ) + self._get_increasing_order(other_items.values()) - self.cached_nodeids.update(k.to_opaque() for k in new_items) + self.cached_nodeids.update(k.as_opaque() for k in new_items) else: - self.cached_nodeids.update(item.id.to_opaque() for item in items) + self.cached_nodeids.update(item.id.as_opaque() for item in items) return res diff --git a/testing/test_nodeid.py b/testing/test_nodeid.py index ee06072720e..0c61907f4df 100644 --- a/testing/test_nodeid.py +++ b/testing/test_nodeid.py @@ -7,7 +7,6 @@ from _pytest._nodeid import ItemNodeId from _pytest._nodeid import OpaqueNodeId from _pytest._nodeid import ParamId -from _pytest._nodeid import to_opaque_node_id from _pytest.nodes import Node from _pytest.reports import TestReport from _pytest.scope import Scope @@ -62,9 +61,9 @@ def test_eq_and_hash(self) -> None: assert {a: 1}[b] == 1 assert {a, b, c} == {a, c} - def test_to_opaque(self) -> None: + def test_as_opaque(self) -> None: node_id = CollectionNodeId(path="a/test_b.py", names=("TestC",)) - opaque = node_id.to_opaque() + opaque = node_id.as_opaque() assert isinstance(opaque, OpaqueNodeId) assert opaque == node_id assert str(opaque) == str(node_id) @@ -101,11 +100,11 @@ def test_eq_and_hash(self) -> None: assert {a: 1}[b] == 1 assert {a, b, c} == {a, c} - def test_to_opaque(self) -> None: + def test_as_opaque(self) -> None: node_id = ItemNodeId( path="a/test_b.py", names=("test_c",), params=(ParamId(id="1"),) ) - opaque = node_id.to_opaque() + opaque = node_id.as_opaque() assert isinstance(opaque, OpaqueNodeId) assert opaque == node_id assert str(opaque) == str(node_id) @@ -243,22 +242,13 @@ def test_from_item_node_id_returns_same_object_and_type(self) -> None: assert isinstance(result, ItemNodeId) -class TestToOpaqueNodeId: - def test_from_collection_node_id(self) -> None: - node_id = CollectionNodeId(path="a/test_b.py", names=("TestC",)) - opaque = to_opaque_node_id(node_id) - assert isinstance(opaque, OpaqueNodeId) - assert opaque == node_id - - def test_from_item_node_id(self) -> None: - node_id = ItemNodeId(path="a/test_b.py", names=("test_c",)) - opaque = to_opaque_node_id(node_id) - assert isinstance(opaque, OpaqueNodeId) - assert opaque == node_id - - def test_from_opaque_node_id_returns_same_object(self) -> None: +class TestOpaqueNodeIdAsOpaque: + def test_returns_self(self) -> None: + """OpaqueNodeId.as_opaque() exists only so that code holding a + NodeId | OpaqueNodeId value can call .as_opaque() unconditionally, + regardless of which concrete type it actually has.""" node_id = OpaqueNodeId.parse("a/test_b.py::test_c") - assert to_opaque_node_id(node_id) is node_id + assert node_id.as_opaque() is node_id class TestWithNodeIdSetter: From f6ef95ea8cc1c3c444d87446fb2e4ff0f2df8e85 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 23 Jul 2026 10:48:33 +0200 Subject: [PATCH 15/27] Use an explicit match statement in coerce_node_id Adds an assert_never arm for exhaustiveness, matching the pattern already used elsewhere in the codebase. Co-Authored-By: Claude Sonnet 5 --- src/_pytest/_nodeid.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/_pytest/_nodeid.py b/src/_pytest/_nodeid.py index 3ecf2870972..878a133afe7 100644 --- a/src/_pytest/_nodeid.py +++ b/src/_pytest/_nodeid.py @@ -42,6 +42,7 @@ from typing import TYPE_CHECKING from typing import TypeVar +from _pytest.compat import assert_never from _pytest.compat import override from _pytest.scope import Scope @@ -233,6 +234,10 @@ def coerce_node_id(nodeid: str | NodeId) -> NodeId | OpaqueNodeId: """Return ``nodeid`` unchanged if already a :data:`NodeId` (live collection data); otherwise treat it as an external nodeid string and wrap it in an :class:`OpaqueNodeId`.""" - if isinstance(nodeid, (CollectionNodeId, ItemNodeId)): - return nodeid - return OpaqueNodeId.parse(nodeid) + match nodeid: + case CollectionNodeId() | ItemNodeId(): + return nodeid + case str(): + return OpaqueNodeId.parse(nodeid) + case _: # pragma: no cover + assert_never(nodeid) From f7029fa0bc542fecebf558d4b60ab3f51c0be101 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 23 Jul 2026 11:30:40 +0200 Subject: [PATCH 16/27] Review changes --- src/_pytest/_nodeid.py | 15 +++++++++------ testing/test_nodeid.py | 12 ++++++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/_pytest/_nodeid.py b/src/_pytest/_nodeid.py index 878a133afe7..f8c62e40a9c 100644 --- a/src/_pytest/_nodeid.py +++ b/src/_pytest/_nodeid.py @@ -1,6 +1,6 @@ """Structured representation of a pytest "nodeid". -A nodeid is a ``::``-separated string identifying a node in the collection +A nodeid is represented as a ``::``-separated string, identifying a node in the collection tree, e.g. ``path/to/test_file.py::TestClass::test_method[param]``. There are three structured, internal representations of this concept, all @@ -13,6 +13,9 @@ function). Carries ``params``; has no ``.child()``/``.leaf()`` at all, so building further collection-tree structure on top of one is a static type error, not just a runtime mistake. +- :class:`OpaqueNodeId` -- A nodeid reconstructed from an external string + source, rather than from live collection. It this no claim to structured + names or params, given they cannot be inferred from the plain string. - :data:`NodeId` -- a type alias, ``CollectionNodeId | ItemNodeId``, for code that genuinely needs to accept/hold either kind. @@ -71,7 +74,7 @@ class ParamId: scope: Scope | None = None -class _CachedStrEqHash: +class _CachedStrEqHashMixin: """Shared ``str(self)`` caching plus cross-type equality/hashing for :class:`CollectionNodeId`, :class:`ItemNodeId` and :class:`OpaqueNodeId`. @@ -98,7 +101,7 @@ def __str__(self) -> str: return base def __eq__(self, other: object) -> bool: - if not isinstance(other, (CollectionNodeId, ItemNodeId, OpaqueNodeId)): + if not isinstance(other, _CachedStrEqHashMixin): return NotImplemented return str(self) == str(other) @@ -107,7 +110,7 @@ def __hash__(self) -> int: @dataclasses.dataclass(frozen=True, eq=False) -class CollectionNodeId(_CachedStrEqHash): +class CollectionNodeId(_CachedStrEqHashMixin): """Structured address for a ``Collector`` node -- one that can still have children built under it. @@ -141,7 +144,7 @@ def as_opaque(self) -> OpaqueNodeId: @dataclasses.dataclass(frozen=True, eq=False) -class ItemNodeId(_CachedStrEqHash): +class ItemNodeId(_CachedStrEqHashMixin): """Structured address for an ``Item`` node -- a leaf, e.g. a test function. Has no ``.child()``/``.leaf()``: nothing ever builds further collection-tree structure on top of an item id. @@ -178,7 +181,7 @@ def as_opaque(self) -> OpaqueNodeId: @dataclasses.dataclass(frozen=True, eq=False) -class OpaqueNodeId(_CachedStrEqHash): +class OpaqueNodeId(_CachedStrEqHashMixin): """A nodeid reconstructed from an external string source (an on-disk cache file, an xdist JSON wire payload, a duck-typed report-like object's ``.nodeid`` attribute, ...), rather than from live collection. diff --git a/testing/test_nodeid.py b/testing/test_nodeid.py index 0c61907f4df..3d41fda1ab9 100644 --- a/testing/test_nodeid.py +++ b/testing/test_nodeid.py @@ -10,6 +10,7 @@ from _pytest.nodes import Node from _pytest.reports import TestReport from _pytest.scope import Scope +import pytest class TestParamId: @@ -200,16 +201,19 @@ def test_rest_none_vs_empty_string(self) -> None: assert str(trailing_sep) == "a/test_b.py::" assert no_sep != trailing_sep - def test_round_trip_matches_original_string(self) -> None: - for s in ( + @pytest.mark.parametrize( + "s", + [ "", "a/test_b.py", "a/test_b.py::", "a/test_b.py::TestC", "a/test_b.py::TestC::test_d", "a/test_b.py::test_c[1-x]", - ): - assert str(OpaqueNodeId.parse(s)) == s + ], + ) + def test_round_trip_matches_original_string(self, s: str) -> None: + assert str(OpaqueNodeId.parse(s)) == s def test_eq_and_hash(self) -> None: a = OpaqueNodeId.parse("a/test_b.py::test_c") From 354e15310373ce4d1c93503b454ed77f6ac5b409 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 23 Jul 2026 11:32:42 +0200 Subject: [PATCH 17/27] Move comment --- src/_pytest/_nodeid.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/_pytest/_nodeid.py b/src/_pytest/_nodeid.py index f8c62e40a9c..20de69f4ae2 100644 --- a/src/_pytest/_nodeid.py +++ b/src/_pytest/_nodeid.py @@ -29,13 +29,6 @@ claim to structured names or params. The legacy ``::``-joined string form remains available (via ``str(node_id)``) for backward compatibility with external plugins, for all types. - -This module must stay a dependency-free leaf: it is imported from -``_pytest.nodes``, which is itself imported by ``_pytest.config``, so -importing anything from ``_pytest.config``/``_pytest.nodes`` here would -create a cycle. Importing :class:`~_pytest.scope.Scope` and -``_pytest.compat`` is safe, since both are themselves dependency-free leaf -modules. """ from __future__ import annotations @@ -45,6 +38,12 @@ from typing import TYPE_CHECKING from typing import TypeVar +# This module must stay a dependency-free leaf: it is imported from +# ``_pytest.nodes``, which is itself imported by ``_pytest.config``, so +# importing anything from ``_pytest.config``/``_pytest.nodes`` here would +# create a cycle. Importing :class:`~_pytest.scope.Scope` and +# ``_pytest.compat`` is safe, since both are themselves dependency-free leaf +# modules. from _pytest.compat import assert_never from _pytest.compat import override from _pytest.scope import Scope From deae8b0b3d8dd481d66daf145a7dfde9b4c4b666 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 23 Jul 2026 12:11:29 +0200 Subject: [PATCH 18/27] Rename _pytest._nodeid to _pytest.nodeid The module is already under the _pytest private package, so an extra leading underscore on the module name itself is redundant. Co-Authored-By: Claude Sonnet 5 --- doc/en/conf.py | 8 ++++---- src/_pytest/cacheprovider.py | 4 ++-- src/_pytest/junitxml.py | 6 +++--- src/_pytest/{_nodeid.py => nodeid.py} | 0 src/_pytest/nodes.py | 12 ++++++------ src/_pytest/python.py | 4 ++-- src/_pytest/reports.py | 16 ++++++++-------- src/_pytest/stepwise.py | 4 ++-- src/_pytest/subtests.py | 4 ++-- src/_pytest/terminal.py | 6 +++--- testing/python/metafunc.py | 2 +- testing/test_mark.py | 2 +- testing/test_nodeid.py | 10 +++++----- 13 files changed, 39 insertions(+), 39 deletions(-) rename src/_pytest/{_nodeid.py => nodeid.py} (100%) diff --git a/doc/en/conf.py b/doc/en/conf.py index 28813641b0f..949fb3e9a78 100644 --- a/doc/en/conf.py +++ b/doc/en/conf.py @@ -90,10 +90,10 @@ ("py:class", "_pytest.recwarn.WarningsChecker"), ("py:class", "_pytest.reports.BaseReport"), ("py:class", "_pytest.reports._WithNodeId"), - ("py:class", "_pytest._nodeid.NodeId"), - ("py:class", "_pytest._nodeid.CollectionNodeId"), - ("py:class", "_pytest._nodeid.ItemNodeId"), - ("py:class", "_pytest._nodeid.OpaqueNodeId"), + ("py:class", "_pytest.nodeid.NodeId"), + ("py:class", "_pytest.nodeid.CollectionNodeId"), + ("py:class", "_pytest.nodeid.ItemNodeId"), + ("py:class", "_pytest.nodeid.OpaqueNodeId"), # Sphinx bugs(?) ("py:class", "RewriteHook"), # Undocumented third parties diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index d94e632526b..b89c0d2b394 100644 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -21,8 +21,6 @@ from .reports import CollectReport from _pytest import nodes from _pytest._io import TerminalWriter -from _pytest._nodeid import ItemNodeId -from _pytest._nodeid import OpaqueNodeId from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import hookimpl @@ -31,6 +29,8 @@ from _pytest.fixtures import fixture from _pytest.fixtures import FixtureRequest from _pytest.main import Session +from _pytest.nodeid import ItemNodeId +from _pytest.nodeid import OpaqueNodeId from _pytest.nodes import Directory from _pytest.nodes import File from _pytest.reports import TestReport diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 1788a0ae360..bef1ae1cdb2 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -21,13 +21,13 @@ from _pytest import timing from _pytest._code.code import ExceptionRepr from _pytest._code.code import ReprFileLocation -from _pytest._nodeid import coerce_node_id -from _pytest._nodeid import NodeId -from _pytest._nodeid import OpaqueNodeId from _pytest.config import Config from _pytest.config import filename_arg from _pytest.config.argparsing import Parser from _pytest.fixtures import FixtureRequest +from _pytest.nodeid import coerce_node_id +from _pytest.nodeid import NodeId +from _pytest.nodeid import OpaqueNodeId from _pytest.reports import _WithNodeId from _pytest.reports import BaseReport from _pytest.reports import CollectReport diff --git a/src/_pytest/_nodeid.py b/src/_pytest/nodeid.py similarity index 100% rename from src/_pytest/_nodeid.py rename to src/_pytest/nodeid.py diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 42b48b9f8be..32ec2bda8da 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -27,9 +27,6 @@ from _pytest._code.code import TerminalRepr from _pytest._code.code import Traceback from _pytest._code.code import TracebackStyle -from _pytest._nodeid import CollectionNodeId -from _pytest._nodeid import ItemNodeId -from _pytest._nodeid import NodeId from _pytest.compat import LEGACY_PATH from _pytest.compat import signature from _pytest.config import Config @@ -37,6 +34,9 @@ from _pytest.mark.structures import Mark from _pytest.mark.structures import MarkDecorator from _pytest.mark.structures import NodeKeywords +from _pytest.nodeid import CollectionNodeId +from _pytest.nodeid import ItemNodeId +from _pytest.nodeid import NodeId from _pytest.outcomes import fail from _pytest.pathlib import absolutepath from _pytest.stash import Stash @@ -301,7 +301,7 @@ def id(self) -> NodeId: .. note:: - Experimental/internal: the shape of :class:`~_pytest._nodeid.NodeId` + Experimental/internal: the shape of :class:`~_pytest.nodeid.NodeId` may change in future releases. """ return self._id @@ -536,7 +536,7 @@ def id(self) -> CollectionNodeId: .. note:: Experimental/internal: the shape of - :class:`~_pytest._nodeid.CollectionNodeId` may change in future + :class:`~_pytest.nodeid.CollectionNodeId` may change in future releases. """ return self._id @@ -705,7 +705,7 @@ def id(self) -> ItemNodeId: .. note:: Experimental/internal: the shape of - :class:`~_pytest._nodeid.ItemNodeId` may change in future + :class:`~_pytest.nodeid.ItemNodeId` may change in future releases. """ return self._id diff --git a/src/_pytest/python.py b/src/_pytest/python.py index e4943428968..3034e5c71fe 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -42,7 +42,6 @@ from _pytest._code.code import TerminalRepr from _pytest._code.code import Traceback from _pytest._io.saferepr import saferepr -from _pytest._nodeid import ParamId from _pytest.compat import ascii_escaped from _pytest.compat import get_default_arg_names from _pytest.compat import get_real_func @@ -71,6 +70,7 @@ from _pytest.mark.structures import Mark from _pytest.mark.structures import MarkDecorator from _pytest.mark.structures import normalize_mark_list +from _pytest.nodeid import ParamId from _pytest.outcomes import fail from _pytest.outcomes import skip from _pytest.pathlib import fnmatch_ex @@ -1226,7 +1226,7 @@ def id(self) -> str: @property def param_ids(self) -> tuple[ParamId, ...]: """The ordered per-parametrize()-call ids, with full argnames/scope - detail. See :class:`~_pytest._nodeid.ParamId`.""" + detail. See :class:`~_pytest.nodeid.ParamId`.""" return tuple(self._idlist) diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index e01b8ce625f..591704e6121 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -29,12 +29,12 @@ from _pytest._code.code import ReprTraceback from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter -from _pytest._nodeid import coerce_node_id -from _pytest._nodeid import CollectionNodeId -from _pytest._nodeid import ItemNodeId -from _pytest._nodeid import NodeId -from _pytest._nodeid import OpaqueNodeId from _pytest.config import Config +from _pytest.nodeid import coerce_node_id +from _pytest.nodeid import CollectionNodeId +from _pytest.nodeid import ItemNodeId +from _pytest.nodeid import NodeId +from _pytest.nodeid import OpaqueNodeId from _pytest.nodes import Collector from _pytest.nodes import Item from _pytest.outcomes import fail @@ -339,7 +339,7 @@ def id(self) -> NodeId | OpaqueNodeId: .. note:: - Experimental/internal: the shape of :class:`~_pytest._nodeid.NodeId` + Experimental/internal: the shape of :class:`~_pytest.nodeid.NodeId` may change in future releases. """ return self._id @@ -367,7 +367,7 @@ def id(self) -> ItemNodeId | OpaqueNodeId: .. note:: Experimental/internal: the shape of - :class:`~_pytest._nodeid.ItemNodeId` may change in future + :class:`~_pytest.nodeid.ItemNodeId` may change in future releases. """ return self._id @@ -528,7 +528,7 @@ def id(self) -> CollectionNodeId | OpaqueNodeId: .. note:: Experimental/internal: the shape of - :class:`~_pytest._nodeid.CollectionNodeId` may change in future + :class:`~_pytest.nodeid.CollectionNodeId` may change in future releases. """ return self._id diff --git a/src/_pytest/stepwise.py b/src/_pytest/stepwise.py index 0c812414c75..0eae9972324 100644 --- a/src/_pytest/stepwise.py +++ b/src/_pytest/stepwise.py @@ -7,12 +7,12 @@ from typing import TYPE_CHECKING from _pytest import nodes -from _pytest._nodeid import ItemNodeId -from _pytest._nodeid import OpaqueNodeId from _pytest.cacheprovider import Cache from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.main import Session +from _pytest.nodeid import ItemNodeId +from _pytest.nodeid import OpaqueNodeId from _pytest.reports import TestReport diff --git a/src/_pytest/subtests.py b/src/_pytest/subtests.py index 9997723dd1c..810dee7b19c 100644 --- a/src/_pytest/subtests.py +++ b/src/_pytest/subtests.py @@ -20,8 +20,6 @@ from _pytest._code import ExceptionInfo from _pytest._io.saferepr import saferepr -from _pytest._nodeid import ItemNodeId -from _pytest._nodeid import OpaqueNodeId from _pytest.capture import CaptureFixture from _pytest.capture import FDCapture from _pytest.capture import SysCapture @@ -34,6 +32,8 @@ from _pytest.logging import catching_logs from _pytest.logging import LogCaptureHandler from _pytest.logging import LoggingPlugin +from _pytest.nodeid import ItemNodeId +from _pytest.nodeid import OpaqueNodeId from _pytest.reports import TestReport from _pytest.runner import CallInfo from _pytest.runner import check_interactive_exception diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 7fe27ce0e9c..f2ec55afdfd 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -38,9 +38,6 @@ from _pytest._code.code import ExceptionRepr from _pytest._io import TerminalWriter from _pytest._io.wcwidth import wcswidth -from _pytest._nodeid import ItemNodeId -from _pytest._nodeid import NodeId -from _pytest._nodeid import OpaqueNodeId import _pytest._version from _pytest.compat import running_on_ci from _pytest.config import _PluggyPlugin @@ -48,6 +45,9 @@ from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config.argparsing import Parser +from _pytest.nodeid import ItemNodeId +from _pytest.nodeid import NodeId +from _pytest.nodeid import OpaqueNodeId from _pytest.nodes import Item from _pytest.nodes import Node from _pytest.pathlib import absolutepath diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 45e243e38e0..fe5b0c13c01 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -17,9 +17,9 @@ from _pytest import fixtures from _pytest import python -from _pytest._nodeid import ItemNodeId from _pytest.compat import getfuncargnames from _pytest.compat import NOTSET +from _pytest.nodeid import ItemNodeId from _pytest.outcomes import fail from _pytest.outcomes import Failed from _pytest.pytester import Pytester diff --git a/testing/test_mark.py b/testing/test_mark.py index d35e50192b2..9fdae2ee762 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -5,10 +5,10 @@ import sys from unittest import mock -from _pytest._nodeid import CollectionNodeId from _pytest.config import ExitCode from _pytest.mark import MarkGenerator from _pytest.mark.structures import EMPTY_PARAMETERSET_OPTION +from _pytest.nodeid import CollectionNodeId from _pytest.nodes import Collector from _pytest.nodes import Node from _pytest.pytester import Pytester diff --git a/testing/test_nodeid.py b/testing/test_nodeid.py index 3d41fda1ab9..52405402ced 100644 --- a/testing/test_nodeid.py +++ b/testing/test_nodeid.py @@ -2,11 +2,11 @@ from unittest import mock -from _pytest._nodeid import coerce_node_id -from _pytest._nodeid import CollectionNodeId -from _pytest._nodeid import ItemNodeId -from _pytest._nodeid import OpaqueNodeId -from _pytest._nodeid import ParamId +from _pytest.nodeid import coerce_node_id +from _pytest.nodeid import CollectionNodeId +from _pytest.nodeid import ItemNodeId +from _pytest.nodeid import OpaqueNodeId +from _pytest.nodeid import ParamId from _pytest.nodes import Node from _pytest.reports import TestReport from _pytest.scope import Scope From b8552b619809cccc5f60441fce6ae36df868896d Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 23 Jul 2026 12:21:43 +0200 Subject: [PATCH 19/27] Fix stale CallSpec2 reference in ParamId docstring after rebase Upstream renamed CallSpec2 to CallSpec (#14742). Co-Authored-By: Claude Sonnet 5 --- src/_pytest/nodeid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/nodeid.py b/src/_pytest/nodeid.py index 20de69f4ae2..f286595c43a 100644 --- a/src/_pytest/nodeid.py +++ b/src/_pytest/nodeid.py @@ -60,7 +60,7 @@ class ParamId: Multiple ``ParamId``s are joined with ``"-"`` to form the legacy ``[bracket]`` content of a nodeid, mirroring - :attr:`_pytest.python.CallSpec2.param_ids`. + :attr:`_pytest.python.CallSpec.param_ids`. ``argnames``/``scope`` are only known when built from live collection data (see ``Function.__init__``) -- an :class:`ItemNodeId` never has one From 83b91094271684e72ce9dcfcb7ff89e6a1b5366f Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 24 Jul 2026 09:35:12 +0200 Subject: [PATCH 20/27] Fix grammar --- src/_pytest/nodeid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/nodeid.py b/src/_pytest/nodeid.py index f286595c43a..bd3501d3c8c 100644 --- a/src/_pytest/nodeid.py +++ b/src/_pytest/nodeid.py @@ -14,7 +14,7 @@ building further collection-tree structure on top of one is a static type error, not just a runtime mistake. - :class:`OpaqueNodeId` -- A nodeid reconstructed from an external string - source, rather than from live collection. It this no claim to structured + source, rather than from live collection. It makes no claim to structured names or params, given they cannot be inferred from the plain string. - :data:`NodeId` -- a type alias, ``CollectionNodeId | ItemNodeId``, for code that genuinely needs to accept/hold either kind. From ebd6ba9951d1d12fa3c5e8a157f1fb3b7dade8b5 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 24 Jul 2026 09:47:34 +0200 Subject: [PATCH 21/27] Use slots=True/kw_only=True on nodeid.py dataclasses Replaces the _CachedStrEqHashMixin with an ABC (_CachedStrEqHash), and turns the object.__setattr__-based _str caching trick into a proper init=False dataclass field (renamed to _str_cache) on each concrete class, so slots=True gives it a real slot. The ABC declares its own __slots__ = () explicitly, since abc.ABC's __slots__ = () does not propagate to subclasses that don't redeclare it. Co-Authored-By: Claude Sonnet 5 --- src/_pytest/nodeid.py | 63 ++++++++++++++++++++++++++----------------- src/_pytest/nodes.py | 2 +- 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/src/_pytest/nodeid.py b/src/_pytest/nodeid.py index bd3501d3c8c..eb27b72155c 100644 --- a/src/_pytest/nodeid.py +++ b/src/_pytest/nodeid.py @@ -33,6 +33,7 @@ from __future__ import annotations +import abc import dataclasses from typing import overload from typing import TYPE_CHECKING @@ -53,7 +54,7 @@ from typing_extensions import Self -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) class ParamId: """One resolved id contributed by a single (possibly stacked) ``parametrize()`` call. @@ -73,34 +74,43 @@ class ParamId: scope: Scope | None = None -class _CachedStrEqHashMixin: +class _CachedStrEqHash(abc.ABC): """Shared ``str(self)`` caching plus cross-type equality/hashing for :class:`CollectionNodeId`, :class:`ItemNodeId` and :class:`OpaqueNodeId`. - Not a dataclass itself -- just method bodies reused by all three, since - they must compare/hash equal to each other by canonical string form - (e.g. a currently-collected ``item.id`` must compare equal to a - matching entry read back from an on-disk cache file), but each builds - its string differently. + An ABC, not a dataclass itself -- just method bodies reused by all + three, since they must compare/hash equal to each other by canonical + string form (e.g. a currently-collected ``item.id`` must compare equal + to a matching entry read back from an on-disk cache file), but each + builds its string differently. Each concrete subclass declares its own + ``_str_cache`` dataclass field (rather than it living here) so that + ``slots=True`` gives it a real slot. """ + # Without this, subclasses would get an instance __dict__ despite their + # own slots=True, since a __slots__-less base class in the MRO grants + # one to every subclass regardless -- abc.ABC's own __slots__ = () does + # not propagate down to subclasses that don't redeclare it themselves. + __slots__ = () + + _str_cache: str | None + + @abc.abstractmethod def _build_str(self) -> str: raise NotImplementedError def __str__(self) -> str: # Lazily compute and cache the string on first access -- it's used # on every __eq__/__hash__ call, so it's worth not repeating the - # join/format work on every comparison. Not a dataclass field: just - # an ad hoc cached attribute. - cached: str | None = getattr(self, "_str", None) - if cached is not None: - return cached + # join/format work on every comparison. + if self._str_cache is not None: + return self._str_cache base = self._build_str() - object.__setattr__(self, "_str", base) + object.__setattr__(self, "_str_cache", base) return base def __eq__(self, other: object) -> bool: - if not isinstance(other, _CachedStrEqHashMixin): + if not isinstance(other, _CachedStrEqHash): return NotImplemented return str(self) == str(other) @@ -108,8 +118,8 @@ def __hash__(self) -> int: return hash(str(self)) -@dataclasses.dataclass(frozen=True, eq=False) -class CollectionNodeId(_CachedStrEqHashMixin): +@dataclasses.dataclass(frozen=True, eq=False, slots=True, kw_only=True) +class CollectionNodeId(_CachedStrEqHash): """Structured address for a ``Collector`` node -- one that can still have children built under it. @@ -122,6 +132,7 @@ class CollectionNodeId(_CachedStrEqHashMixin): path: str names: tuple[str, ...] = () + _str_cache: str | None = dataclasses.field(default=None, init=False, repr=False) @override def _build_str(self) -> str: @@ -129,11 +140,11 @@ def _build_str(self) -> str: def child(self, name: str) -> CollectionNodeId: """Return a new CollectionNodeId for a child collector node.""" - return CollectionNodeId(self.path, (*self.names, name)) + return CollectionNodeId(path=self.path, names=(*self.names, name)) def leaf(self, name: str, params: tuple[ParamId, ...]) -> ItemNodeId: """Return a new ItemNodeId for a terminal item node.""" - return ItemNodeId(self.path, (*self.names, name), params) + return ItemNodeId(path=self.path, names=(*self.names, name), params=params) def as_opaque(self) -> OpaqueNodeId: """Return the OpaqueNodeId form of this id, for code that only ever @@ -142,8 +153,8 @@ def as_opaque(self) -> OpaqueNodeId: return OpaqueNodeId.parse(str(self)) -@dataclasses.dataclass(frozen=True, eq=False) -class ItemNodeId(_CachedStrEqHashMixin): +@dataclasses.dataclass(frozen=True, eq=False, slots=True, kw_only=True) +class ItemNodeId(_CachedStrEqHash): """Structured address for an ``Item`` node -- a leaf, e.g. a test function. Has no ``.child()``/``.leaf()``: nothing ever builds further collection-tree structure on top of an item id. @@ -159,6 +170,7 @@ class ItemNodeId(_CachedStrEqHashMixin): path: str names: tuple[str, ...] = () params: tuple[ParamId, ...] = () + _str_cache: str | None = dataclasses.field(default=None, init=False, repr=False) @override def _build_str(self) -> str: @@ -179,8 +191,8 @@ def as_opaque(self) -> OpaqueNodeId: NodeId = CollectionNodeId | ItemNodeId -@dataclasses.dataclass(frozen=True, eq=False) -class OpaqueNodeId(_CachedStrEqHashMixin): +@dataclasses.dataclass(frozen=True, eq=False, slots=True, kw_only=True) +class OpaqueNodeId(_CachedStrEqHash): """A nodeid reconstructed from an external string source (an on-disk cache file, an xdist JSON wire payload, a duck-typed report-like object's ``.nodeid`` attribute, ...), rather than from live collection. @@ -198,15 +210,16 @@ class OpaqueNodeId(_CachedStrEqHashMixin): # "::" was present at all (distinct from "" after a trailing "::", for # lossless round-tripping through str.partition()). rest: str | None = None + _str_cache: str | None = dataclasses.field(default=None, init=False, repr=False) @classmethod def parse(cls, nodeid: str) -> OpaqueNodeId: """Split a nodeid string into its path and an opaque remainder.""" path, sep, rest = nodeid.partition("::") - self = cls(path, rest if sep else None) + self = cls(path=path, rest=rest if sep else None) # We already have the original string in hand -- cache it directly - # as _str instead of letting __str__ reconstruct it later. - object.__setattr__(self, "_str", nodeid) + # as _str_cache instead of letting __str__ reconstruct it later. + object.__setattr__(self, "_str_cache", nodeid) return self @override diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 32ec2bda8da..ae79cd6f4ad 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -205,7 +205,7 @@ def __init__( # a full ItemNodeId via parent.id.leaf(...) instead of # reaching this branch. So this is always a Collector id. node_path, *names = nodeid.split("::") - self._id = CollectionNodeId(node_path, tuple(names)) + self._id = CollectionNodeId(path=node_path, names=tuple(names)) else: if not self.parent: raise TypeError("nodeid or parent must be provided") From 0a13d23d2cfbdf1e5b9e03a525fa6a54e8c7362b Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 24 Jul 2026 09:51:27 +0200 Subject: [PATCH 22/27] Doc changes --- src/_pytest/nodeid.py | 14 -------------- src/_pytest/nodes.py | 2 ++ 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/_pytest/nodeid.py b/src/_pytest/nodeid.py index eb27b72155c..1ac0e314edf 100644 --- a/src/_pytest/nodeid.py +++ b/src/_pytest/nodeid.py @@ -39,12 +39,6 @@ from typing import TYPE_CHECKING from typing import TypeVar -# This module must stay a dependency-free leaf: it is imported from -# ``_pytest.nodes``, which is itself imported by ``_pytest.config``, so -# importing anything from ``_pytest.config``/``_pytest.nodes`` here would -# create a cycle. Importing :class:`~_pytest.scope.Scope` and -# ``_pytest.compat`` is safe, since both are themselves dependency-free leaf -# modules. from _pytest.compat import assert_never from _pytest.compat import override from _pytest.scope import Scope @@ -227,14 +221,6 @@ def _build_str(self) -> str: return self.path if self.rest is None else f"{self.path}::{self.rest}" def as_opaque(self) -> Self: - """Return self. - - Only added for consistency and ease of use with - :meth:`CollectionNodeId.as_opaque`/:meth:`ItemNodeId.as_opaque`, so - callers holding a ``NodeId | OpaqueNodeId`` value can call - ``.as_opaque()`` unconditionally without checking which kind they - have. - """ return self diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index ae79cd6f4ad..cbdc9b89826 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -299,6 +299,8 @@ def nodeid(self) -> str: def id(self) -> NodeId: """The structured (non-string) form of :attr:`nodeid`. + :meta private: + .. note:: Experimental/internal: the shape of :class:`~_pytest.nodeid.NodeId` From c2774c90483d9fe1384921f38bc501ab74fdc529 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 24 Jul 2026 10:40:48 +0200 Subject: [PATCH 23/27] Remove cross-type equality between CollectionNodeId/ItemNodeId/OpaqueNodeId These three classes no longer share a base class and no longer compare equal to each other just because their str() forms coincide -- an OpaqueNodeId reconstructed from an untrusted external string (an on-disk cache file, xdist JSON, ...) should never be silently treated as "the same node" as a live, trustworthy CollectionNodeId/ItemNodeId. Same-type equality/hashing is now handled by each dataclass's own generated __eq__/__hash__ instead of a shared, string-based implementation; str() caching is reimplemented independently on each class. Traced every place that could rely on the old cross-type behavior at runtime and fixed each real dependency by normalizing explicitly via .as_opaque() before comparing/storing, mirroring the pattern already used by cacheprovider.py's LFPlugin/NFPlugin: - stepwise.py: StepwiseCacheInfo.last_failed is compared against a live item.id/report.id after being loaded from the on-disk cache as an OpaqueNodeId -- this is the entire mechanism --stepwise uses to find where to resume. - terminal.py: _progress_nodeids_reported/_timing_nodeids_reported and the teardown-section lookup helpers process every report in a run, including SubtestReport, whose .id is always OpaqueNodeId (built via a JSON round-trip even in-process), so they'd otherwise double-count a subtest and its enclosing test as different nodes. - junitxml.py: LogXML.node_reporters has the same SubtestReport-vs- enclosing-test issue -- without normalizing, a failed subtest and its enclosing test's failure ended up as two separate elements in the JUnit XML instead of one (verified by hand before/after). testing/test_reports.py's JSON round-trip test is updated to compare the reconstructed report's _id by string instead of by equality, since a deserialized report's _id is now correctly a different (less trusted) type than the original live report's. Co-Authored-By: Claude Sonnet 5 --- src/_pytest/junitxml.py | 20 ++++---- src/_pytest/nodeid.py | 103 +++++++++++++++------------------------- src/_pytest/stepwise.py | 11 ++--- src/_pytest/terminal.py | 28 +++++------ testing/test_nodeid.py | 67 +++++--------------------- testing/test_reports.py | 13 ++++- 6 files changed, 87 insertions(+), 155 deletions(-) diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index bef1ae1cdb2..6e80f6353a0 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -88,7 +88,7 @@ def merge_family(left, right) -> None: class _NodeReporter: - def __init__(self, node_id: NodeId | OpaqueNodeId, xml: LogXML) -> None: + def __init__(self, node_id: OpaqueNodeId, xml: LogXML) -> None: self.id = node_id self.xml = xml self.add_stats = self.xml.add_stats @@ -483,9 +483,7 @@ def __init__( self.stats: dict[str, int] = dict.fromkeys( ["error", "passed", "failure", "skipped"], 0 ) - self.node_reporters: dict[ - tuple[NodeId | OpaqueNodeId, object], _NodeReporter - ] = {} + self.node_reporters: dict[tuple[OpaqueNodeId, object], _NodeReporter] = {} self.node_reporters_ordered: list[_NodeReporter] = [] self.global_properties: list[tuple[str, str]] = [] @@ -498,7 +496,7 @@ def __init__( self.family = "xunit1" def finalize(self, report: TestReport) -> None: - node_id = report.id + node_id = report.id.as_opaque() # Local hack to handle xdist report order. workernode = getattr(report, "node", None) reporter = self.node_reporters.pop((node_id, workernode)) @@ -510,11 +508,11 @@ def finalize(self, report: TestReport) -> None: reporter.finalize() def node_reporter(self, report: BaseReport | NodeId | str) -> _NodeReporter: - if isinstance(report, (NodeId, str)): - node_id = coerce_node_id(report) + if isinstance(report, NodeId | str): + node_id = coerce_node_id(report).as_opaque() elif isinstance(report, _WithNodeId): # Covers both TestReport and CollectReport. - node_id = report.id + node_id = report.id.as_opaque() else: # Some callers (and tests) pass duck-typed report-like objects # that are neither of the above but do provide a nodeid. @@ -582,7 +580,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: rep for rep in self.open_reports if ( - rep.id == report.id + rep.id.as_opaque() == report.id.as_opaque() and getattr(rep, "item_index", None) == report_ii and getattr(rep, "worker_id", None) == report_wid ) @@ -600,7 +598,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: # element for that item (#3850). self.cnt_double_fail_tests += int( ( - report.id, + report.id.as_opaque(), getattr(report, "node", None), ) in self.node_reporters @@ -629,7 +627,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: rep for rep in self.open_reports if ( - rep.id == report.id + rep.id.as_opaque() == report.id.as_opaque() and getattr(rep, "item_index", None) == report_ii and getattr(rep, "worker_id", None) == report_wid ) diff --git a/src/_pytest/nodeid.py b/src/_pytest/nodeid.py index 1ac0e314edf..2b4b9290fce 100644 --- a/src/_pytest/nodeid.py +++ b/src/_pytest/nodeid.py @@ -33,14 +33,12 @@ from __future__ import annotations -import abc import dataclasses from typing import overload from typing import TYPE_CHECKING from typing import TypeVar from _pytest.compat import assert_never -from _pytest.compat import override from _pytest.scope import Scope @@ -68,52 +66,8 @@ class ParamId: scope: Scope | None = None -class _CachedStrEqHash(abc.ABC): - """Shared ``str(self)`` caching plus cross-type equality/hashing for - :class:`CollectionNodeId`, :class:`ItemNodeId` and :class:`OpaqueNodeId`. - - An ABC, not a dataclass itself -- just method bodies reused by all - three, since they must compare/hash equal to each other by canonical - string form (e.g. a currently-collected ``item.id`` must compare equal - to a matching entry read back from an on-disk cache file), but each - builds its string differently. Each concrete subclass declares its own - ``_str_cache`` dataclass field (rather than it living here) so that - ``slots=True`` gives it a real slot. - """ - - # Without this, subclasses would get an instance __dict__ despite their - # own slots=True, since a __slots__-less base class in the MRO grants - # one to every subclass regardless -- abc.ABC's own __slots__ = () does - # not propagate down to subclasses that don't redeclare it themselves. - __slots__ = () - - _str_cache: str | None - - @abc.abstractmethod - def _build_str(self) -> str: - raise NotImplementedError - - def __str__(self) -> str: - # Lazily compute and cache the string on first access -- it's used - # on every __eq__/__hash__ call, so it's worth not repeating the - # join/format work on every comparison. - if self._str_cache is not None: - return self._str_cache - base = self._build_str() - object.__setattr__(self, "_str_cache", base) - return base - - def __eq__(self, other: object) -> bool: - if not isinstance(other, _CachedStrEqHash): - return NotImplemented - return str(self) == str(other) - - def __hash__(self) -> int: - return hash(str(self)) - - -@dataclasses.dataclass(frozen=True, eq=False, slots=True, kw_only=True) -class CollectionNodeId(_CachedStrEqHash): +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class CollectionNodeId: """Structured address for a ``Collector`` node -- one that can still have children built under it. @@ -126,11 +80,19 @@ class CollectionNodeId(_CachedStrEqHash): path: str names: tuple[str, ...] = () - _str_cache: str | None = dataclasses.field(default=None, init=False, repr=False) + _str_cache: str | None = dataclasses.field( + default=None, init=False, repr=False, compare=False + ) - @override - def _build_str(self) -> str: - return "::".join((self.path, *self.names)) + def __str__(self) -> str: + # Lazily compute and cache the string on first access -- it's used + # on every __eq__/__hash__ call site elsewhere (e.g. as_opaque()), + # so it's worth not repeating the join work on every call. + if self._str_cache is not None: + return self._str_cache + s = "::".join((self.path, *self.names)) + object.__setattr__(self, "_str_cache", s) + return s def child(self, name: str) -> CollectionNodeId: """Return a new CollectionNodeId for a child collector node.""" @@ -147,8 +109,8 @@ def as_opaque(self) -> OpaqueNodeId: return OpaqueNodeId.parse(str(self)) -@dataclasses.dataclass(frozen=True, eq=False, slots=True, kw_only=True) -class ItemNodeId(_CachedStrEqHash): +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class ItemNodeId: """Structured address for an ``Item`` node -- a leaf, e.g. a test function. Has no ``.child()``/``.leaf()``: nothing ever builds further collection-tree structure on top of an item id. @@ -164,14 +126,18 @@ class ItemNodeId(_CachedStrEqHash): path: str names: tuple[str, ...] = () params: tuple[ParamId, ...] = () - _str_cache: str | None = dataclasses.field(default=None, init=False, repr=False) + _str_cache: str | None = dataclasses.field( + default=None, init=False, repr=False, compare=False + ) - @override - def _build_str(self) -> str: - base = "::".join((self.path, *self.names)) + def __str__(self) -> str: + if self._str_cache is not None: + return self._str_cache + s = "::".join((self.path, *self.names)) if self.params: - base += "[" + "-".join(p.id for p in self.params) + "]" - return base + s += "[" + "-".join(p.id for p in self.params) + "]" + object.__setattr__(self, "_str_cache", s) + return s def as_opaque(self) -> OpaqueNodeId: """Return the OpaqueNodeId form of this id, for code that only ever @@ -185,8 +151,8 @@ def as_opaque(self) -> OpaqueNodeId: NodeId = CollectionNodeId | ItemNodeId -@dataclasses.dataclass(frozen=True, eq=False, slots=True, kw_only=True) -class OpaqueNodeId(_CachedStrEqHash): +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class OpaqueNodeId: """A nodeid reconstructed from an external string source (an on-disk cache file, an xdist JSON wire payload, a duck-typed report-like object's ``.nodeid`` attribute, ...), rather than from live collection. @@ -204,7 +170,9 @@ class OpaqueNodeId(_CachedStrEqHash): # "::" was present at all (distinct from "" after a trailing "::", for # lossless round-tripping through str.partition()). rest: str | None = None - _str_cache: str | None = dataclasses.field(default=None, init=False, repr=False) + _str_cache: str | None = dataclasses.field( + default=None, init=False, repr=False, compare=False + ) @classmethod def parse(cls, nodeid: str) -> OpaqueNodeId: @@ -216,9 +184,12 @@ def parse(cls, nodeid: str) -> OpaqueNodeId: object.__setattr__(self, "_str_cache", nodeid) return self - @override - def _build_str(self) -> str: - return self.path if self.rest is None else f"{self.path}::{self.rest}" + def __str__(self) -> str: + if self._str_cache is not None: + return self._str_cache + s = self.path if self.rest is None else f"{self.path}::{self.rest}" + object.__setattr__(self, "_str_cache", s) + return s def as_opaque(self) -> Self: return self diff --git a/src/_pytest/stepwise.py b/src/_pytest/stepwise.py index 0eae9972324..87a803072dd 100644 --- a/src/_pytest/stepwise.py +++ b/src/_pytest/stepwise.py @@ -11,7 +11,6 @@ from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.main import Session -from _pytest.nodeid import ItemNodeId from _pytest.nodeid import OpaqueNodeId from _pytest.reports import TestReport @@ -72,7 +71,7 @@ def pytest_sessionfinish(session: Session) -> None: @dataclasses.dataclass class StepwiseCacheInfo: # The nodeid of the last failed test. - last_failed: ItemNodeId | OpaqueNodeId | None + last_failed: OpaqueNodeId | None # The number of tests in the last time --stepwise was run. # We use this information as a simple way to invalidate the cache information, avoiding @@ -156,7 +155,7 @@ def pytest_collection_modifyitems( # Check all item nodes until we find a match on last failed. failed_index = None for index, item in enumerate(items): - if item.id == self.cached_info.last_failed: + if item.id.as_opaque() == self.cached_info.last_failed: failed_index = index break @@ -181,13 +180,13 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: if self.skip: # Remove test from the failed ones (if it exists) and unset the skip option # to make sure the following tests will not be skipped. - if report.id == self.cached_info.last_failed: + if report.id.as_opaque() == self.cached_info.last_failed: self.cached_info.last_failed = None self.skip = False else: # Mark test as the last failing and interrupt the test session. - self.cached_info.last_failed = report.id + self.cached_info.last_failed = report.id.as_opaque() assert self.session is not None self.session.shouldstop = ( "Test failed, continuing from this test next run." @@ -197,7 +196,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: # If the test was actually run and did pass. if report.when == "call": # Remove test from the failed ones, if exists. - if report.id == self.cached_info.last_failed: + if report.id.as_opaque() == self.cached_info.last_failed: self.cached_info.last_failed = None def pytest_report_collectionfinish(self) -> list[str] | None: diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index f2ec55afdfd..7241a7c41a3 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -45,8 +45,6 @@ from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config.argparsing import Parser -from _pytest.nodeid import ItemNodeId -from _pytest.nodeid import NodeId from _pytest.nodeid import OpaqueNodeId from _pytest.nodes import Item from _pytest.nodes import Node @@ -403,8 +401,8 @@ def __init__(self, config: Config, file: TextIO | None = None) -> None: # isatty should be a method but was wrongly implemented as a boolean. # We use CallableBool here to support both. self.isatty = compat.CallableBool(file.isatty()) - self._progress_nodeids_reported: set[ItemNodeId | OpaqueNodeId] = set() - self._timing_nodeids_reported: set[NodeId | OpaqueNodeId] = set() + self._progress_nodeids_reported: set[OpaqueNodeId] = set() + self._timing_nodeids_reported: set[OpaqueNodeId] = set() self._show_progress_info = self._determine_show_progress_info() self._collect_report_last_write = timing.Instant() self._already_displayed_warnings: int | None = None @@ -653,7 +651,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: markup = {"yellow": True} else: markup = {} - self._progress_nodeids_reported.add(rep.id) + self._progress_nodeids_reported.add(rep.id.as_opaque()) if self.config.get_verbosity(Config.VERBOSITY_TEST_CASES) <= 0: self._tw.write(letter, **markup) # When running in xdist, the logreport and logfinish of multiple @@ -744,7 +742,9 @@ def _get_progress_information_message(self) -> str: ) current_location = all_reports[-1].location[0] not_reported = [ - r for r in all_reports if r.id not in self._timing_nodeids_reported + r + for r in all_reports + if r.id.as_opaque() not in self._timing_nodeids_reported ] tests_in_module = sum( i.location[0] == current_location for i in self._session.items @@ -756,7 +756,9 @@ def _get_progress_information_message(self) -> str: ) last_in_module = tests_completed == tests_in_module if self.showlongtestinfo or last_in_module: - self._timing_nodeids_reported.update(r.id for r in not_reported) + self._timing_nodeids_reported.update( + r.id.as_opaque() for r in not_reported + ) return format_node_duration( sum(r.duration for r in not_reported if isinstance(r, TestReport)) ) @@ -1170,19 +1172,17 @@ def summary_passes_combined( msg = self._getfailureheadline(rep) self.write_sep("_", msg, green=True, bold=True) self._outrep_summary(rep) - self._handle_teardown_sections(rep.id) + self._handle_teardown_sections(rep.id.as_opaque()) - def _get_teardown_reports( - self, node_id: ItemNodeId | OpaqueNodeId - ) -> list[TestReport]: + def _get_teardown_reports(self, node_id: OpaqueNodeId) -> list[TestReport]: reports = self.getreports("") return [ report for report in reports - if report.when == "teardown" and report.id == node_id + if report.when == "teardown" and report.id.as_opaque() == node_id ] - def _handle_teardown_sections(self, node_id: ItemNodeId | OpaqueNodeId) -> None: + def _handle_teardown_sections(self, node_id: OpaqueNodeId) -> None: for report in self._get_teardown_reports(node_id): self.print_teardown_sections(report) @@ -1232,7 +1232,7 @@ def summary_failures_combined( msg = self._getfailureheadline(rep) self.write_sep("_", msg, red=True, bold=True) self._outrep_summary(rep) - self._handle_teardown_sections(rep.id) + self._handle_teardown_sections(rep.id.as_opaque()) def summary_errors(self) -> None: if self.config.option.tbstyle != "no": diff --git a/testing/test_nodeid.py b/testing/test_nodeid.py index 52405402ced..aea235eca48 100644 --- a/testing/test_nodeid.py +++ b/testing/test_nodeid.py @@ -66,7 +66,6 @@ def test_as_opaque(self) -> None: node_id = CollectionNodeId(path="a/test_b.py", names=("TestC",)) opaque = node_id.as_opaque() assert isinstance(opaque, OpaqueNodeId) - assert opaque == node_id assert str(opaque) == str(node_id) @@ -107,64 +106,20 @@ def test_as_opaque(self) -> None: ) opaque = node_id.as_opaque() assert isinstance(opaque, OpaqueNodeId) - assert opaque == node_id assert str(opaque) == str(node_id) -class TestCrossTypeEquality: - def test_eq_and_hash_across_all_three_types(self) -> None: - """Equality/hash must be based on the canonical string form, not the - raw fields or concrete class -- a CollectionNodeId/ItemNodeId built - from live collection data and an OpaqueNodeId built from a plain - string must all compare equal and hash equal when they represent - the same logical node. This matters for e.g. comparing a - currently-collected item.id against a node id read back from an - on-disk cache file, and for containers that deliberately mix live - and cache-sourced ids (e.g. cacheprovider's lastfailed). - """ - collection = CollectionNodeId(path="a/test_b.py", names=("test_c",)) - item = CollectionNodeId(path="a/test_b.py").leaf( - "test_c", (ParamId(id="1", argnames=("x",), scope=Scope.Function),) - ) - opaque_collection = OpaqueNodeId.parse("a/test_b.py::test_c") - opaque_item = OpaqueNodeId.parse("a/test_b.py::test_c[1]") - - assert str(collection) == str(opaque_collection) - assert collection == opaque_collection - assert hash(collection) == hash(opaque_collection) - - assert str(item) == str(opaque_item) - assert item == opaque_item - assert hash(item) == hash(opaque_item) - - # Different concrete classes must not spuriously compare equal just - # because they're both "some kind of NodeId". - assert collection != item - assert collection != opaque_item - assert item != opaque_collection - - by_node_id: dict[object, str] = {item: "value"} - assert by_node_id[opaque_item] == "value" - - def test_string_construction_via_node_init(self) -> None: - """Node.__init__'s string branch (used e.g. by FSCollector for bare - paths) still supports a "::"-joined string for backward - compatibility, splitting it into clean names -- this never sees a - "[params]" bracket in practice (Function always pre-builds a real - ItemNodeId instead), so producing a CollectionNodeId here is - safe.""" - session = mock.Mock() - session.own_markers = [] - session.parent = None - session.nodeid = "" - session.id = CollectionNodeId(path="") - node = Node.from_parent( - session, name="ignored", nodeid="a/test_b.py::TestC::test_d" - ) - assert node.id == CollectionNodeId( - path="a/test_b.py", names=("TestC", "test_d") - ) - assert node.nodeid == "a/test_b.py::TestC::test_d" +def test_string_construction_via_node_init() -> None: + session = mock.Mock() + session.own_markers = [] + session.parent = None + session.nodeid = "" + session.id = CollectionNodeId(path="") + node = Node.from_parent( + session, name="ignored", nodeid="a/test_b.py::TestC::test_d" + ) + assert node.id == CollectionNodeId(path="a/test_b.py", names=("TestC", "test_d")) + assert node.nodeid == "a/test_b.py::TestC::test_d" class TestOpaqueNodeId: diff --git a/testing/test_reports.py b/testing/test_reports.py index e5c5126b29c..e79a5a6dd7e 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -60,8 +60,17 @@ def test_fail(): # Check assembled == rep assert a.__dict__.keys() == rep.__dict__.keys() for key in rep.__dict__.keys(): - if key != "longrepr": - assert getattr(a, key) == getattr(rep, key) + if key == "longrepr": + continue + if key == "_id": + # _from_json() always reconstructs an OpaqueNodeId (built + # from the plain nodeid string on the wire), while the + # original live report's _id is a structured ItemNodeId -- + # these are deliberately different, non-equal types (see + # _pytest/nodeid.py), so compare their string form instead. + assert str(a._id) == str(rep._id) + continue + assert getattr(a, key) == getattr(rep, key) assert rep.longrepr.reprcrash is not None assert a.longrepr.reprcrash is not None assert rep.longrepr.reprcrash.lineno == a.longrepr.reprcrash.lineno From bd8815fca68f0424eac894d53eabfa66ccb27a8e Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 24 Jul 2026 14:21:38 +0200 Subject: [PATCH 24/27] Remove str support from Node.__init__'s nodeid parameter Session and FSCollector, the only two real callers, both build a nodeid that is always effectively a bare CollectionNodeId (Session's literal "", FSCollector's filesystem-relative path -- neither ever contains "::"), so both can build CollectionNodeId directly instead of going through Node.__init__'s string-splitting fallback. This also closes a latent type-safety gap: Item.__init__ already narrows its own signature to ItemNodeId | None to prevent an Item ending up with a non-ItemNodeId _id, but that was only a static restriction -- the shared Node.__init__ body didn't itself guard against a caller ignoring the type hint and passing a raw str through an Item subclass, which would have silently produced a CollectionNodeId in an Item's _id slot. Removing str support from Node.__init__ entirely closes this structurally rather than by convention. Co-Authored-By: Claude Sonnet 5 --- src/_pytest/main.py | 3 ++- src/_pytest/nodes.py | 27 +++++++++++---------------- testing/test_nodeid.py | 16 ---------------- 3 files changed, 13 insertions(+), 33 deletions(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 1b337e20c7e..57a1174fbd8 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -34,6 +34,7 @@ from _pytest.config import UsageError from _pytest.config.argparsing import OverrideIniAction from _pytest.config.argparsing import Parser +from _pytest.nodeid import CollectionNodeId from _pytest.outcomes import exit from _pytest.pathlib import absolutepath from _pytest.pathlib import bestrelpath @@ -608,7 +609,7 @@ def __init__(self, config: Config) -> None: parent=None, config=config, session=self, - nodeid="", + nodeid=CollectionNodeId(path=""), ) self.testsfailed = 0 self.testscollected = 0 diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index cbdc9b89826..42d0ab0f94e 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -155,7 +155,7 @@ def __init__( session: Session | None = None, fspath: None = None, path: Path | None = None, - nodeid: str | NodeId | None = None, + nodeid: NodeId | None = None, ) -> None: #: A unique name within the scope of the parent node. self.name: str = name @@ -196,16 +196,7 @@ def __init__( self.extra_keyword_matches: set[str] = set() if nodeid is not None: - if isinstance(nodeid, NodeId): - self._id = nodeid - else: - # Every real caller here passes either "" (session root) or - # a bare collector path (never containing "::") -- Function, - # the only Item with real params/brackets, always pre-builds - # a full ItemNodeId via parent.id.leaf(...) instead of - # reaching this branch. So this is always a Collector id. - node_path, *names = nodeid.split("::") - self._id = CollectionNodeId(path=node_path, names=tuple(names)) + self._id = nodeid else: if not self.parent: raise TypeError("nodeid or parent must be provided") @@ -609,7 +600,7 @@ def __init__( parent: Node | None = None, config: Config | None = None, session: Session | None = None, - nodeid: str | None = None, + nodeid: CollectionNodeId | None = None, ) -> None: if path_or_parent: if isinstance(path_or_parent, Node): @@ -638,12 +629,16 @@ def __init__( if nodeid is None: try: - nodeid = str(self.path.relative_to(session.config.rootpath)) + path_str: str | None = str( + self.path.relative_to(session.config.rootpath) + ) except ValueError: - nodeid = _check_initialpaths_for_relpath(session._initialpaths, path) + path_str = _check_initialpaths_for_relpath(session._initialpaths, path) - if nodeid: - nodeid = norm_sep(nodeid) + if path_str: + path_str = norm_sep(path_str) + if path_str is not None: + nodeid = CollectionNodeId(path=path_str) super().__init__( name=name, diff --git a/testing/test_nodeid.py b/testing/test_nodeid.py index aea235eca48..00594ac1a46 100644 --- a/testing/test_nodeid.py +++ b/testing/test_nodeid.py @@ -1,13 +1,10 @@ from __future__ import annotations -from unittest import mock - from _pytest.nodeid import coerce_node_id from _pytest.nodeid import CollectionNodeId from _pytest.nodeid import ItemNodeId from _pytest.nodeid import OpaqueNodeId from _pytest.nodeid import ParamId -from _pytest.nodes import Node from _pytest.reports import TestReport from _pytest.scope import Scope import pytest @@ -109,19 +106,6 @@ def test_as_opaque(self) -> None: assert str(opaque) == str(node_id) -def test_string_construction_via_node_init() -> None: - session = mock.Mock() - session.own_markers = [] - session.parent = None - session.nodeid = "" - session.id = CollectionNodeId(path="") - node = Node.from_parent( - session, name="ignored", nodeid="a/test_b.py::TestC::test_d" - ) - assert node.id == CollectionNodeId(path="a/test_b.py", names=("TestC", "test_d")) - assert node.nodeid == "a/test_b.py::TestC::test_d" - - class TestOpaqueNodeId: def test_root(self) -> None: node_id = OpaqueNodeId.parse("") From 006f0c369965dba1339fbde8858bea6eb558ccde Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 24 Jul 2026 14:30:57 +0200 Subject: [PATCH 25/27] Add a runtime check that Node.__init__'s nodeid is a NodeId or None Guards against external plugins that may still be passing a raw nodeid string (now unsupported), raising a clear error pointing at Node.from_parent() instead of silently misbehaving. Co-Authored-By: Claude Sonnet 5 --- src/_pytest/nodes.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 42d0ab0f94e..5c15574d846 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -196,6 +196,12 @@ def __init__( self.extra_keyword_matches: set[str] = set() if nodeid is not None: + if not isinstance(nodeid, NodeId): # pragma: no cover + raise ValueError( + f"nodeid must be a NodeId (CollectionNodeId/ItemNodeId) instance " + f"or None, got {nodeid!r}. Do not pass nodeid explicitly -- use " + f"Node.from_parent() and let pytest compute it automatically." + ) self._id = nodeid else: if not self.parent: From 2a90f4f5e97267f189d0283531d67124baa80d01 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 24 Jul 2026 14:45:58 +0200 Subject: [PATCH 26/27] Remove Node.__hash__ Node hashed by nodeid but never overrode __eq__ (stays identity-based), so the custom hash provided no actual behavioral difference for any dict/set correctness -- collisions are always disambiguated via ==, which is identity-based here regardless of hash value. Every real dict/set of Node/Item/Collector objects in the codebase already relies on identity semantics (e.g. main.py's _collect_one_node explicitly notes fixture registration is "keyed by node identity"). Removing it lets Node fall back to the default identity-based object.__hash__, which is the behaviorally-correct pairing for its default identity-based __eq__. Co-Authored-By: Claude Sonnet 5 --- src/_pytest/nodes.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 5c15574d846..03bb9aa3ab7 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -305,9 +305,6 @@ def id(self) -> NodeId: """ return self._id - def __hash__(self) -> int: - return hash(self._id) - def setup(self) -> None: pass From 67c9d542fba59fbd34f1b6a896ca54663914fb68 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 24 Jul 2026 15:31:24 +0200 Subject: [PATCH 27/27] Fold _WithNodeId into BaseReport; use match in junitxml's node_reporter _WithNodeId was only ever used as BaseReport's sole base and had no other consumers left (isinstance(x, _WithNodeId) checks were already removed), so there was no reason to keep it as a separate mixin class -- its nodeid/id properties now live directly on BaseReport. Also rewrites junitxml.LogXML.node_reporter's initial if/else as an explicit match statement with a final assert_never arm for exhaustiveness, and removes the now-stale _WithNodeId nitpick_ignore entry from doc/en/conf.py. Co-Authored-By: Claude Sonnet 5 --- doc/en/conf.py | 1 - src/_pytest/junitxml.py | 20 ++++++------ src/_pytest/reports.py | 66 +++++++++++++++------------------------- testing/test_junitxml.py | 8 ++--- 4 files changed, 37 insertions(+), 58 deletions(-) diff --git a/doc/en/conf.py b/doc/en/conf.py index 949fb3e9a78..207656a4a4f 100644 --- a/doc/en/conf.py +++ b/doc/en/conf.py @@ -89,7 +89,6 @@ ("py:class", "_pytest.python_api.RaisesContext"), ("py:class", "_pytest.recwarn.WarningsChecker"), ("py:class", "_pytest.reports.BaseReport"), - ("py:class", "_pytest.reports._WithNodeId"), ("py:class", "_pytest.nodeid.NodeId"), ("py:class", "_pytest.nodeid.CollectionNodeId"), ("py:class", "_pytest.nodeid.ItemNodeId"), diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 6e80f6353a0..0506c1c8418 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -21,14 +21,16 @@ from _pytest import timing from _pytest._code.code import ExceptionRepr from _pytest._code.code import ReprFileLocation +from _pytest.compat import assert_never from _pytest.config import Config from _pytest.config import filename_arg from _pytest.config.argparsing import Parser from _pytest.fixtures import FixtureRequest from _pytest.nodeid import coerce_node_id +from _pytest.nodeid import CollectionNodeId +from _pytest.nodeid import ItemNodeId from _pytest.nodeid import NodeId from _pytest.nodeid import OpaqueNodeId -from _pytest.reports import _WithNodeId from _pytest.reports import BaseReport from _pytest.reports import CollectReport from _pytest.reports import TestReport @@ -508,15 +510,13 @@ def finalize(self, report: TestReport) -> None: reporter.finalize() def node_reporter(self, report: BaseReport | NodeId | str) -> _NodeReporter: - if isinstance(report, NodeId | str): - node_id = coerce_node_id(report).as_opaque() - elif isinstance(report, _WithNodeId): - # Covers both TestReport and CollectReport. - node_id = report.id.as_opaque() - else: - # Some callers (and tests) pass duck-typed report-like objects - # that are neither of the above but do provide a nodeid. - node_id = OpaqueNodeId.parse(report.nodeid) + match report: + case CollectionNodeId() | ItemNodeId() | str(): + node_id = coerce_node_id(report).as_opaque() + case BaseReport(): + node_id = report.id.as_opaque() + case _: # pragma: no cover + assert_never(report) # Local hack to handle xdist report order. workernode = getattr(report, "node", None) diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 591704e6121..4d58fa3192c 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -70,12 +70,34 @@ class BaseReport: None | ExceptionInfo[BaseException] | tuple[str, int, str] | str | TerminalRepr ) sections: list[tuple[str, str]] - nodeid: str outcome: Literal["passed", "failed", "skipped"] + _id: NodeId | OpaqueNodeId + def __init__(self, **kw: Any) -> None: self.__dict__.update(kw) + @property + def nodeid(self) -> str: + return str(self._id) + + @nodeid.setter + def nodeid(self, value: str) -> None: + self._id = OpaqueNodeId.parse(value) + + @property + def id(self) -> NodeId | OpaqueNodeId: + """The structured (non-string) form of ``nodeid``. + + :meta private: + + .. note:: + + Experimental/internal: the shape of :class:`~_pytest.nodeid.NodeId` + may change in future releases. + """ + return self._id + if TYPE_CHECKING: # Can have arbitrary fields given to __init__(). def __getattr__(self, key: str) -> Any: ... @@ -307,45 +329,7 @@ def _format_exception_group_all_skipped_longrepr( return longrepr -class _WithNodeId: - """Mixin providing the ``nodeid``/``id`` property pair, backed by - ``self._id: NodeId | OpaqueNodeId``. - - A report's id is a ``NodeId`` when built from live collection data (see - ``TestReport.from_item_and_call``) and an ``OpaqueNodeId`` when built - from an external string (e.g. JSON-deserialized via ``_from_json``, or - assigned through the ``nodeid`` setter below). - - Deliberately not added to :class:`BaseReport` itself: several tests - define ad hoc ``BaseReport`` subclasses that set ``nodeid`` as a plain - class attribute, relying on ``BaseReport.__init__``'s generic - ``self.__dict__.update(kw)``. Keeping ``BaseReport`` untouched means - those subclasses are unaffected regardless of this plumbing. - """ - - _id: NodeId | OpaqueNodeId - - @property - def nodeid(self) -> str: - return str(self._id) - - @nodeid.setter - def nodeid(self, value: str) -> None: - self._id = OpaqueNodeId.parse(value) - - @property - def id(self) -> NodeId | OpaqueNodeId: - """The structured (non-string) form of ``nodeid``. - - .. note:: - - Experimental/internal: the shape of :class:`~_pytest.nodeid.NodeId` - may change in future releases. - """ - return self._id - - -class TestReport(_WithNodeId, BaseReport): +class TestReport(BaseReport): """Basic test report object (also used for setup and teardown calls if they fail). @@ -511,7 +495,7 @@ def from_item_and_call(cls, item: Item, call: CallInfo[None]) -> TestReport: @final -class CollectReport(_WithNodeId, BaseReport): +class CollectReport(BaseReport): """Collection report object. Reports can contain arbitrary extra attributes. diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index 5d4ddd49803..a6729b5fc70 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -1249,11 +1249,11 @@ def test_unicode_issue368(pytester: Pytester) -> None: class Report(BaseReport): longrepr = ustr sections: list[tuple[str, str]] = [] - nodeid = "something" location = "tests/filename.py", 42, "TestClass.method" when = "teardown" test_report = cast(TestReport, Report()) + test_report.nodeid = "something" # hopefully this is not too brittle ... log.pytest_sessionstart() @@ -1560,10 +1560,6 @@ def test_global_properties(pytester: Pytester, xunit_family: str) -> None: path = pytester.path.joinpath("test_global_properties.xml") log = LogXML(str(path), None, family=xunit_family) - class Report(BaseReport): - sections: list[tuple[str, str]] = [] - nodeid = "test_node_id" - log.pytest_sessionstart() log.add_global_property("foo", "1") log.add_global_property("bar", "2") @@ -1598,11 +1594,11 @@ def test_url_property(pytester: Pytester) -> None: class Report(BaseReport): longrepr = "FooBarBaz" sections: list[tuple[str, str]] = [] - nodeid = "something" location = "tests/filename.py", 42, "TestClass.method" url = test_url test_report = cast(TestReport, Report()) + test_report.nodeid = "something" log.pytest_sessionstart() node_reporter = log._opentestcase(test_report)