From 3f76aab668a3069f45ea935827bf5de8af38de7c Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 11:05:45 +0200 Subject: [PATCH 01/35] 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 4dab8f0fb03..9a06202dbd0 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -22,10 +22,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 @@ -87,8 +91,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 @@ -322,7 +326,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 @@ -481,7 +485,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]] = [] @@ -494,10 +498,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)) @@ -505,18 +509,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) @@ -570,7 +584,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 ) @@ -588,7 +602,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 @@ -617,7 +631,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 be0ea5b4d05..4fdba723585 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 @@ -1170,8 +1171,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) @@ -1186,6 +1188,7 @@ def setmulti( param_index: int, nodeid: str, ) -> CallSpec: + argnames = tuple(argnames) params = self.params.copy() indices = self.indices.copy() arg2scope = dict(self._arg2scope) @@ -1197,11 +1200,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)], ) @@ -1213,7 +1223,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: @@ -1691,7 +1707,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 72fe10e96c7..afde8758f71 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: ExceptionInfo[BaseException] | tuple[str, int, str] @@ -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 cb723d134b9..27c5739845a 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 852153b9215..7d7d118b329 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 @@ -407,8 +408,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 @@ -660,7 +661,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 @@ -751,7 +752,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 @@ -763,7 +764,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)) ) @@ -938,7 +939,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: @@ -1177,18 +1178,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: @@ -1237,7 +1238,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 566071d47a1..9a1b0e179a2 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 c70376e7015..ad8b7260ccd 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 _EmptyParameterSetMark @@ -1148,6 +1149,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 2b91f16177d6445696605253bfe5f51d0f0aef6c Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 11:11:38 +0200 Subject: [PATCH 02/35] 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 d48740a09582204c2194ea3c264ab8e08c112e02 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 12:03:31 +0200 Subject: [PATCH 03/35] 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 9a06202dbd0..2608ce50902 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -23,7 +23,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 @@ -510,17 +509,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 afde8758f71..31ce467869d 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 b20da6eff765db9b69e9e7c56bbfeca5b65975c4 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 12:19:05 +0200 Subject: [PATCH 04/35] 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 8603cbdbfcbc5d751c7e8155f1c0a97950ade8e2 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 12:24:59 +0200 Subject: [PATCH 05/35] 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 9942f369712695efa8d4a5ec132181cd2e1604d9 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 12:33:48 +0200 Subject: [PATCH 06/35] 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 fa607c8a4e96b8384c6e93eb5e81b9f31d4926f6 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 12:33:54 +0200 Subject: [PATCH 07/35] 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 543e4dad4132d17637a45a2eb5f066bb2bf30a5b Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 14:32:05 +0200 Subject: [PATCH 08/35] 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 2608ce50902..117a9aaba97 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -22,7 +22,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 @@ -90,7 +92,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 @@ -484,7 +486,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]] = [] @@ -510,14 +514,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 31ce467869d..e6024d55461 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 7d7d118b329..71ad63136da 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 @@ -408,8 +409,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 @@ -1180,7 +1181,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 @@ -1188,7 +1189,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 77d24ff427883bf5d9c8cdd295d494f71e3ad710 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 15:05:14 +0200 Subject: [PATCH 09/35] 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 ecb95e0b9f1..620c2b26215 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 e6024d55461..99b5d5313cd 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 6005798a7b705c8c07e558cd577e09893a3743f1 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 16:37:39 +0200 Subject: [PATCH 10/35] 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 620c2b26215..d1242cbd654 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 117a9aaba97..37b8d9e4d66 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -31,6 +31,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 @@ -121,19 +122,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 @@ -214,12 +217,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: @@ -542,7 +545,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 @@ -649,7 +652,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 4fdba723585..2759984a778 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1172,7 +1172,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) @@ -1707,15 +1707,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 99b5d5313cd..e63067d4554 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: ExceptionInfo[BaseException] | tuple[str, int, str] 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 71ad63136da..c952e1c5f06 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 @@ -409,7 +410,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() @@ -1181,7 +1182,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 @@ -1189,7 +1192,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 9a1b0e179a2..b21ef472c6f 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 bee1cf2fc75..66bc5397450 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -22,6 +22,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 @@ -1279,8 +1280,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 ad8b7260ccd..23b0f0e9b22 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 _EmptyParameterSetMark @@ -1145,11 +1145,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 0be32e6ad45e5fb7c6539df6e608823e1a9fac86 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 16:44:32 +0200 Subject: [PATCH 11/35] 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 161294ca13531127e7d3ae5d9bd8fa6b492eda71 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 16:56:13 +0200 Subject: [PATCH 12/35] 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 ffc337699a2e2238ba15debf30021cd048db306f Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 22 Jul 2026 17:51:05 +0200 Subject: [PATCH 13/35] 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 3d60aa01709405113bfa1d188f2108bb675122de Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 23 Jul 2026 10:30:37 +0200 Subject: [PATCH 14/35] 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 5f72ff80950d64d0ece66e5b3500982ff84859c3 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 23 Jul 2026 10:48:33 +0200 Subject: [PATCH 15/35] 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 396982f25019d29a3ec9b3b94d524821104b0ca8 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 23 Jul 2026 11:30:40 +0200 Subject: [PATCH 16/35] 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 e9bccc73a6182070f135df53b7f375527c86c4b1 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 23 Jul 2026 11:32:42 +0200 Subject: [PATCH 17/35] 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 6950878edb1fa2c2528cdbbfc001002cde9ff853 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 23 Jul 2026 12:11:29 +0200 Subject: [PATCH 18/35] 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 d1242cbd654..ef263c6f290 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 37b8d9e4d66..40e7a7a359a 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -22,13 +22,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 2759984a778..3f0fbf679e8 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 @@ -72,6 +71,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 @@ -1228,7 +1228,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 e63067d4554..92da607dc64 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 c952e1c5f06..9f89bca852b 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 b21ef472c6f..1daa8d79202 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 23b0f0e9b22..f13212ccb92 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -5,11 +5,11 @@ 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 _EmptyParameterSetMark 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 bb23f2805de2fae1a527d734627e85c303c33e50 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 23 Jul 2026 12:21:43 +0200 Subject: [PATCH 19/35] 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 de2b0e0dccc5d2a92ca5a22abe7815e144f836fb Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 24 Jul 2026 09:35:12 +0200 Subject: [PATCH 20/35] 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 b1e3e95d76ef81d76b08da84141aa6d264da1507 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 24 Jul 2026 09:47:34 +0200 Subject: [PATCH 21/35] 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 d495b3d1773bd4c975d4e827ba1baf8fb4573118 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 24 Jul 2026 09:51:27 +0200 Subject: [PATCH 22/35] 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 3ff11cd9daa1aeaa0f9e64398992c347e515c6db Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 24 Jul 2026 10:40:48 +0200 Subject: [PATCH 23/35] 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 40e7a7a359a..0f7cddad03e 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -93,7 +93,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 @@ -489,9 +489,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]] = [] @@ -504,7 +502,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)) @@ -516,11 +514,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. @@ -588,7 +586,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 ) @@ -606,7 +604,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 @@ -635,7 +633,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 9f89bca852b..909f767f21e 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 @@ -410,8 +408,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 @@ -663,7 +661,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 @@ -754,7 +752,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 @@ -766,7 +766,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)) ) @@ -1180,19 +1182,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) @@ -1242,7 +1242,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 8277a2d1d595fd7cbf9f772ade9a3a8385b173e3 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 24 Jul 2026 14:21:38 +0200 Subject: [PATCH 24/35] 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 e5808469c3638a71e405af159ff21625be87f6ac Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 24 Jul 2026 14:30:57 +0200 Subject: [PATCH 25/35] 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 09acd5df29ed96a373a8dd9b7877c9586ee2240d Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 24 Jul 2026 14:45:58 +0200 Subject: [PATCH 26/35] 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 b6565f069a15011427669880bad04eb36493cc48 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 24 Jul 2026 15:31:24 +0200 Subject: [PATCH 27/35] 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 ef263c6f290..aa229505a7f 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 0f7cddad03e..129151b42ca 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -22,14 +22,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 @@ -514,15 +516,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 92da607dc64..5860c3b52e8 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -70,12 +70,34 @@ class BaseReport: ExceptionInfo[BaseException] | tuple[str, int, str] | str | TerminalRepr | None ) 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 66bc5397450..3b51495ac6b 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -1270,11 +1270,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() @@ -1581,10 +1581,6 @@ def test_global_properties(pytester: Pytester, xunit_family: _JunitFamily) -> No 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") @@ -1619,11 +1615,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) From 481409d8342a46fcc1c5b2265c97e1a1f66701a7 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 5 Aug 2026 08:37:26 -0300 Subject: [PATCH 28/35] Replace remaining nodeid string-splitting with OpaqueNodeId.path/.rest Eight call sites that extracted the path portion of a nodeid via split("::", ...)[0] / find("::") now use OpaqueNodeId.parse(s).path instead, matching the pattern already used in cacheprovider/stepwise/ subtests/reports for the same operation. - reports.BaseReport.fspath: _id.path directly (no parse needed; _id already holds the structured form) - config._do_configure anchor stripping: find/if/slice -> .parse().path - config.cwd_relative_nodeid: split/join -> .parse()/.path/.rest - config/findpaths.get_file_part_from_node_id: split -> .parse().path - terminal.write_fspath_result: split -> .parse().path - terminal._locationline verbosity check: split -> .parse().path - terminal warnings Counter: split -> .parse().path - terminal._get_node_id_with_markup: split/join -> .parse()/.path/.rest Sites that need the interior :: names decomposed (junitxml mangle_test_address, main.py resolve_collection_argument, pytester matchreport) are intentionally left as-is: OpaqueNodeId deliberately keeps everything after the first "::" opaque and unsplit. Co-Authored-By: Claude Opus 4.8 --- src/_pytest/config/__init__.py | 17 +++++++---------- src/_pytest/config/findpaths.py | 3 ++- src/_pytest/reports.py | 2 +- src/_pytest/terminal.py | 16 +++++++--------- 4 files changed, 17 insertions(+), 21 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index c1a80ed163e..d60415a4837 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -68,6 +68,7 @@ from _pytest.config.argparsing import Parser import _pytest.deprecated import _pytest.hookspec +from _pytest.nodeid import OpaqueNodeId from _pytest.outcomes import fail from _pytest.outcomes import Skipped from _pytest.pathlib import absolutepath @@ -646,11 +647,7 @@ def _set_initial_conftests( anchors = [] for initial_path in args: - path = str(initial_path) - # remove node-id syntax - i = path.find("::") - if i != -1: - path = path[:i] + path = OpaqueNodeId.parse(str(initial_path)).path anchor = absolutepath(invocation_dir / path) # Ensure we do not break if what appears to be an anchor # is in fact a very long option (#10169, #11394). @@ -1333,12 +1330,12 @@ def notify_exception( def cwd_relative_nodeid(self, nodeid: str) -> str: # nodeid's are relative to the rootpath, compute relative to cwd. if self.invocation_params.dir != self.rootpath: - base_path_part, *nodeid_part = nodeid.split("::") - # Only process path part - fullpath = self.rootpath / base_path_part + oid = OpaqueNodeId.parse(nodeid) + fullpath = self.rootpath / oid.path relative_path = bestrelpath(self.invocation_params.dir, fullpath) - - nodeid = "::".join([relative_path, *nodeid_part]) + nodeid = ( + relative_path if oid.rest is None else f"{relative_path}::{oid.rest}" + ) return nodeid @classmethod diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index 2a4bed319a9..2231d672e6f 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -13,6 +13,7 @@ import iniconfig from .exceptions import UsageError +from _pytest.nodeid import OpaqueNodeId from _pytest.outcomes import fail from _pytest.pathlib import absolutepath from _pytest.pathlib import commonpath @@ -241,7 +242,7 @@ def is_option(x: str) -> bool: return x.startswith("-") def get_file_part_from_node_id(x: str) -> str: - return x.split("::", maxsplit=1)[0] + return OpaqueNodeId.parse(x).path def get_dir_from_path(path: Path) -> Path: if path.is_dir(): diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 5860c3b52e8..ab557311695 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -189,7 +189,7 @@ def skipped(self) -> bool: @property def fspath(self) -> str: """The path portion of the reported node, as a string.""" - return self.nodeid.split("::")[0] + return self._id.path @property def count_towards_summary(self) -> bool: diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 909f767f21e..8823c691017 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -487,7 +487,7 @@ def hasopt(self, char: str) -> bool: return char in self.reportchars def write_fspath_result(self, nodeid: str, res: str, **markup: bool) -> None: - fspath = self.config.rootpath / nodeid.split("::", maxsplit=1)[0] + fspath = self.config.rootpath / OpaqueNodeId.parse(nodeid).path if self.currentfspath is None or fspath != self.currentfspath: if self.currentfspath is not None and self._show_progress_info: self._write_progress_information_filling_space() @@ -1070,7 +1070,7 @@ def mkrel(nodeid: str) -> str: if fspath: res = mkrel(nodeid) if self.verbosity >= 2 and ( - nodeid.split("::", maxsplit=1)[0] != nodes.norm_sep(fspath) + OpaqueNodeId.parse(nodeid).path != nodes.norm_sep(fspath) ): res += " <- " + bestrelpath(self.startpath, Path(fspath)) else: @@ -1138,7 +1138,7 @@ def collapsed_location_report(reports: list[WarningReport]) -> str: return "\n".join(map(str, locations)) counts_by_filename = Counter( - str(loc).split("::", 1)[0] for loc in locations + OpaqueNodeId.parse(str(loc)).path for loc in locations ) return "\n".join( "{}: {} warning{}".format(k, v, "s" if v > 1 else "") @@ -1529,12 +1529,10 @@ def _build_collect_only_summary_stats_line( def _get_node_id_with_markup(tw: TerminalWriter, config: Config, rep: BaseReport): nodeid = config.cwd_relative_nodeid(rep.nodeid) - path, *parts = nodeid.split("::") - if parts: - parts_markup = tw.markup("::".join(parts), bold=True) - return path + "::" + parts_markup - else: - return path + oid = OpaqueNodeId.parse(nodeid) + if oid.rest is not None: + return f"{oid.path}::{tw.markup(oid.rest, bold=True)}" + return oid.path def _format_trimmed(format: str, msg: str, available_width: int) -> str | None: From 2df09b9dd15d0a543516dc5a60cd528e9b96a048 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 5 Aug 2026 09:00:19 -0300 Subject: [PATCH 29/35] Extend OpaqueNodeId to (path, names, params); convert mangle_test_address/matchreport OpaqueNodeId previously kept everything after the first "::" as one opaque `rest` field, because the "-"-separated ParamId call boundaries inside the bracket can't be reliably recovered. But the two other decompositions ARE reliable and lossless: the "::" name segments (names can't contain "::", "[", or "]"), and peeling the whole "[...]" as one raw params string. Replace the `rest` field with `names: tuple[str, ...]` and `params: str | None`, and keep `rest` as a derived property (so the two existing .rest consumers in terminal.py / config/__init__.py need no change). The parse order peels the bracket FIRST so a "::" inside a bracket (issue #469, e.g. test_func[double::colon]) is never mistaken for a name separator. With this extension, two call sites that needed individual name segments can now use the structured type: - junitxml.mangle_test_address: drop the hand-rolled partition("[") / split("::") in favour of OpaqueNodeId.parse(); output is identical for all well-formed nodeids, and the double::colon-in-params case (issue #469) is handled correctly at the type level rather than by coincidence. - pytester.matchreport: replace rep.nodeid.split("::") membership test with (rep.id.path, *rep.id.names), which also matches parametrized tests by their base name (e.g. matchreport("test_func") now matches "test_func[x]") and handles ::-in-params correctly. Co-Authored-By: Claude Opus 4.8 --- src/_pytest/junitxml.py | 10 +++--- src/_pytest/nodeid.py | 77 ++++++++++++++++++++++++++++------------- src/_pytest/pytester.py | 2 +- testing/test_nodeid.py | 46 ++++++++++++++++++------ 4 files changed, 95 insertions(+), 40 deletions(-) diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 129151b42ca..b440ef60c2f 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -459,13 +459,15 @@ def pytest_unconfigure(config: Config) -> None: def mangle_test_address(address: str) -> list[str]: - path, possible_open_bracket, params = address.partition("[") - names = path.split("::") + oid = OpaqueNodeId.parse(address) + names = [oid.path, *oid.names] # Convert file path to dotted path. names[0] = names[0].replace(nodes.SEP, ".") names[0] = re.sub(r"\.py$", "", names[0]) - # Put any params back. - names[-1] += possible_open_bracket + params + # Put any params back as one opaque bracket (the '-'-separated + # param-call-boundary structure inside cannot be reliably recovered). + if oid.params is not None: + names[-1] += f"[{oid.params}]" return names diff --git a/src/_pytest/nodeid.py b/src/_pytest/nodeid.py index 2b4b9290fce..951e4f7ff65 100644 --- a/src/_pytest/nodeid.py +++ b/src/_pytest/nodeid.py @@ -14,21 +14,18 @@ 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 makes no claim to structured - names or params, given they cannot be inferred from the plain string. + source, rather than from live collection. It recovers the ``::``-separated + ``names`` and the raw ``[params]`` bracket contents (a single opaque + string), but makes no claim to the *internal* structure of the params + string: the ``"-"`` delimiter joining separate ``parametrize()``-call ids + cannot be reliably distinguished from a literal ``"-"`` in a param value, + so :class:`OpaqueNodeId` deliberately leaves ``params`` as one unparsed + string rather than fabricating :class:`ParamId` boundaries. - :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. +The legacy ``::``-joined string form remains available (via ``str(node_id)``) +for backward compatibility with external plugins, for all types. """ from __future__ import annotations @@ -158,27 +155,43 @@ class OpaqueNodeId: object's ``.nodeid`` attribute, ...), rather than from live collection. 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. + claim to the *internal* structure of the params string: the ``"-"`` + delimiter is used both to join sub-ids within one ``parametrize()`` call + and to join separate stacked calls, so it cannot be reliably decomposed + back into :class:`ParamId` boundaries. ``params`` is therefore kept as a + single raw string rather than guessing. There is no + ``.child()``/``.leaf()`` -- 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 + #: ``::``-separated name segments, recovered reliably from the string. + #: Empty tuple when there is no ``::`` in the nodeid. + names: tuple[str, ...] = () + #: Raw contents inside the outermost ``[...]`` bracket, kept as one + #: opaque string (the param-call-boundary structure cannot be recovered). + #: ``None`` means no bracket at all; ``""`` means an empty ``[]``. + params: str | None = None _str_cache: str | None = dataclasses.field( default=None, init=False, repr=False, compare=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=path, rest=rest if sep else None) + """Parse a nodeid string into its path, names, and raw params.""" + path, sep, tail = nodeid.partition("::") + if not sep: + # No "::" -- everything is the path. Never interpret a bracket + # here: file paths can legitimately contain "[" (e.g. test[1].py). + self = cls(path=path) + else: + # Peel the params bracket off the tail FIRST, so a "::" that + # appears *inside* a params bracket is never mistaken for a name + # separator. + name_part, bracket, param_part = tail.partition("[") + names = tuple(name_part.split("::")) + params = param_part.removesuffix("]") if bracket else None + self = cls(path=path, names=names, params=params) # We already have the original string in hand -- cache it directly # as _str_cache instead of letting __str__ reconstruct it later. object.__setattr__(self, "_str_cache", nodeid) @@ -187,10 +200,24 @@ def parse(cls, nodeid: str) -> OpaqueNodeId: 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}" + s = "::".join((self.path, *self.names)) + if self.params is not None: + s += f"[{self.params}]" object.__setattr__(self, "_str_cache", s) return s + @property + def rest(self) -> str | None: + """Everything after the first ``"::"`` as a single string, or + ``None`` when there is no ``"::"`` (i.e. ``names`` is empty). + """ + if not self.names: + return None + s = "::".join(self.names) + if self.params is not None: + s += f"[{self.params}]" + return s + def as_opaque(self) -> Self: return self diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index b69b58732ef..a11dab05a8f 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -368,7 +368,7 @@ def matchreport( continue if when and rep.when != when: continue - if not inamepart or inamepart in rep.nodeid.split("::"): + if not inamepart or inamepart in (rep.id.path, *rep.id.names): values.append(rep) if not values: raise ValueError( diff --git a/testing/test_nodeid.py b/testing/test_nodeid.py index 00594ac1a46..2a4b2e4b389 100644 --- a/testing/test_nodeid.py +++ b/testing/test_nodeid.py @@ -114,23 +114,47 @@ def test_root(self) -> None: def test_path_only(self) -> None: node_id = OpaqueNodeId.parse("a/b/test_c.py") - assert node_id == OpaqueNodeId(path="a/b/test_c.py", rest=None) + assert node_id == OpaqueNodeId(path="a/b/test_c.py") + assert node_id.names == () + assert node_id.params is None assert str(node_id) == "a/b/test_c.py" - def test_with_rest(self) -> None: + def test_with_names(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") + assert node_id == OpaqueNodeId(path="a/test_b.py", names=("TestC", "test_d")) + assert node_id.names == ("TestC", "test_d") + assert node_id.params is None - 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.""" + def test_names_and_params(self) -> None: node_id = OpaqueNodeId.parse("a/test_b.py::test_c[1-x]") + assert node_id.names == ("test_c",) + assert node_id.params == "1-x" assert node_id.rest == "test_c[1-x]" + def test_params_boundary_not_inferred(self) -> None: + """The '-' inside params is used both to join sub-ids within one + parametrize() call and to join separate stacked calls, so the internal + ParamId boundaries cannot be reliably recovered. params is therefore + a single opaque string, not decomposed further.""" + node_id = OpaqueNodeId.parse("a/test_b.py::test_c[a-b-c]") + assert node_id.params == "a-b-c" + + def test_double_colon_inside_params(self) -> None: + """A '::' inside the [params] bracket must NOT be mistaken for a + name separator (issue #469, e.g. a param value 'double::colon').""" + node_id = OpaqueNodeId.parse("a/test_b.py::test_func[double::colon]") + assert node_id.names == ("test_func",) + assert node_id.params == "double::colon" + assert str(node_id) == "a/test_b.py::test_func[double::colon]" + + def test_empty_params(self) -> None: + node_id = OpaqueNodeId.parse("a/test_b.py::test_c[]") + assert node_id.names == ("test_c",) + assert node_id.params == "" + 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(). + trailing "::" -- both must round-trip losslessly. """ no_sep = OpaqueNodeId.parse("a/test_b.py") trailing_sep = OpaqueNodeId.parse("a/test_b.py::") @@ -149,6 +173,8 @@ def test_rest_none_vs_empty_string(self) -> None: "a/test_b.py::TestC", "a/test_b.py::TestC::test_d", "a/test_b.py::test_c[1-x]", + "a/test_b.py::test_func[double::colon]", + "a/test_b.py::TestC::test_d[a-b]", ], ) def test_round_trip_matches_original_string(self, s: str) -> None: @@ -168,7 +194,7 @@ def test_eq_and_hash(self) -> None: 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") + assert node_id == OpaqueNodeId(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",)) @@ -205,5 +231,5 @@ def test_nodeid_setter_builds_opaque_node_id(self) -> 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.id == OpaqueNodeId(path="a/test_b.py", names=("test_d",)) assert report.nodeid == "a/test_b.py::test_d" From 952e6ce5123b9c19e9c8c121202c9396db345c82 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 5 Aug 2026 09:51:02 -0300 Subject: [PATCH 30/35] Drop ParamId; merge OpaqueNodeId into ItemNodeId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed that ParamId.argnames/.scope are written once (python.py) and read nowhere; the only field ever consumed from a ParamId is .id ("-".join(p.id ...)). With argnames/scope removed, ParamId reduces to a plain string, so CallSpec._idlist becomes Sequence[str] and Function.__init__ passes callspec.id (the already-joined bracket string) instead of callspec.param_ids. With ItemNodeId.params now str | None (flat bracket string), it is structurally identical to OpaqueNodeId, so OpaqueNodeId is removed: ItemNodeId now also serves as the external-string boundary type (via ItemNodeId.parse, moved from OpaqueNodeId.parse). CollectionNodeId gains a symmetric CollectionNodeId.parse for CollectReport reconstruction. as_opaque() → as_leaf(): the ~27 .as_opaque() calls that normalised live ids and external ids to a single OpaqueNodeId key type are replaced by .as_leaf() on the genuinely mixed containers (LFPlugin.lastfailed, junitxml node_reporters, terminal teardown/progress sets). Item-only containers (NFPlugin.cached_nodeids, stepwise.last_failed, subtests.failed_subtests_key, terminal progress/timing sets) drop the normalisation entirely and use .id directly, since both live and deserialized items are now the same ItemNodeId type. TestReport._id: ItemNodeId (was ItemNodeId | OpaqueNodeId) CollectReport._id: CollectionNodeId (was CollectionNodeId | OpaqueNodeId) BaseReport._id: NodeId (was NodeId | OpaqueNodeId) The public nodeid: str attribute, fspath: str, and the JSON wire format are unchanged. Co-Authored-By: Claude Opus 4.8 --- src/_pytest/cacheprovider.py | 31 +++--- src/_pytest/config/__init__.py | 6 +- src/_pytest/config/findpaths.py | 4 +- src/_pytest/junitxml.py | 19 ++-- src/_pytest/nodeid.py | 181 +++++++++++++------------------- src/_pytest/nodes.py | 2 +- src/_pytest/python.py | 25 ++--- src/_pytest/reports.py | 29 ++--- src/_pytest/stepwise.py | 16 ++- src/_pytest/subtests.py | 5 +- src/_pytest/terminal.py | 30 +++--- testing/test_nodeid.py | 139 ++++++++++-------------- 12 files changed, 206 insertions(+), 281 deletions(-) diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index b89c0d2b394..b2428d14130 100644 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -30,7 +30,6 @@ 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 @@ -273,7 +272,7 @@ def sort_key(node: nodes.Item | nodes.Collector) -> bool: # Only filter with known failures. if not self._collected_at_least_one_failure: - if not any(x.id.as_opaque() in lastfailed for x in result): + if not any(x.id.as_leaf() in lastfailed for x in result): return res self.lfplugin.config.pluginmanager.register( LFPluginCollSkipfiles(self.lfplugin), "lfplugin-collskip" @@ -284,7 +283,7 @@ def sort_key(node: nodes.Item | nodes.Collector) -> bool: result[:] = [ x for x in result - if x.id.as_opaque() in lastfailed + if x.id.as_leaf() in lastfailed # Include any passed arguments (not trivial to filter). or session.isinitpath(x.path) # Keep all sub-collectors. @@ -318,8 +317,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[OpaqueNodeId, bool] = { - OpaqueNodeId.parse(k): v + self.lastfailed: dict[ItemNodeId, bool] = { + ItemNodeId.parse(k): v for k, v in config.cache.get("cache/lastfailed", {}).items() } self._previously_failed_count: int | None = None @@ -350,21 +349,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.as_opaque(), None) + self.lastfailed.pop(report.id, None) elif report.failed: - self.lastfailed[report.id.as_opaque()] = True + self.lastfailed[report.id] = True def pytest_collectreport(self, report: CollectReport) -> None: passed = report.outcome in ("passed", "skipped") if passed: - report_id = report.id.as_opaque() + report_id = report.id.as_leaf() if report_id in self.lastfailed: self.lastfailed.pop(report_id) self.lastfailed.update( - (item.id.as_opaque(), True) for item in report.result + (item.id.as_leaf(), True) for item in report.result ) else: - self.lastfailed[report.id.as_opaque()] = True + self.lastfailed[report.id.as_leaf()] = True @hookimpl(wrapper=True, tryfirst=True) def pytest_collection_modifyitems( @@ -379,7 +378,7 @@ def pytest_collection_modifyitems( previously_failed = [] previously_passed = [] for item in items: - if item.id.as_opaque() in self.lastfailed: + if item.id in self.lastfailed: previously_failed.append(item) else: previously_passed.append(item) @@ -437,8 +436,8 @@ def __init__(self, config: Config) -> None: self.config = config self.active = config.option.newfirst assert config.cache is not None - self.cached_nodeids: set[OpaqueNodeId] = { - OpaqueNodeId.parse(s) for s in config.cache.get("cache/nodeids", []) + self.cached_nodeids: set[ItemNodeId] = { + ItemNodeId.parse(s) for s in config.cache.get("cache/nodeids", []) } @hookimpl(wrapper=True, tryfirst=True) @@ -449,7 +448,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.as_opaque() not in self.cached_nodeids: + if item.id not in self.cached_nodeids: new_items[item.id] = item else: other_items[item.id] = item @@ -457,9 +456,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.as_opaque() for k in new_items) + self.cached_nodeids.update(new_items) else: - self.cached_nodeids.update(item.id.as_opaque() for item in items) + self.cached_nodeids.update(item.id for item in items) return res diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index d60415a4837..d93d8dcc2d1 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -68,7 +68,7 @@ from _pytest.config.argparsing import Parser import _pytest.deprecated import _pytest.hookspec -from _pytest.nodeid import OpaqueNodeId +from _pytest.nodeid import ItemNodeId from _pytest.outcomes import fail from _pytest.outcomes import Skipped from _pytest.pathlib import absolutepath @@ -647,7 +647,7 @@ def _set_initial_conftests( anchors = [] for initial_path in args: - path = OpaqueNodeId.parse(str(initial_path)).path + path = ItemNodeId.parse(str(initial_path)).path anchor = absolutepath(invocation_dir / path) # Ensure we do not break if what appears to be an anchor # is in fact a very long option (#10169, #11394). @@ -1330,7 +1330,7 @@ def notify_exception( def cwd_relative_nodeid(self, nodeid: str) -> str: # nodeid's are relative to the rootpath, compute relative to cwd. if self.invocation_params.dir != self.rootpath: - oid = OpaqueNodeId.parse(nodeid) + oid = ItemNodeId.parse(nodeid) fullpath = self.rootpath / oid.path relative_path = bestrelpath(self.invocation_params.dir, fullpath) nodeid = ( diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index 2231d672e6f..18d2171c64b 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -13,7 +13,7 @@ import iniconfig from .exceptions import UsageError -from _pytest.nodeid import OpaqueNodeId +from _pytest.nodeid import ItemNodeId from _pytest.outcomes import fail from _pytest.pathlib import absolutepath from _pytest.pathlib import commonpath @@ -242,7 +242,7 @@ def is_option(x: str) -> bool: return x.startswith("-") def get_file_part_from_node_id(x: str) -> str: - return OpaqueNodeId.parse(x).path + return ItemNodeId.parse(x).path def get_dir_from_path(path: Path) -> Path: if path.is_dir(): diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index b440ef60c2f..a5a40d86a87 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -31,7 +31,6 @@ from _pytest.nodeid import CollectionNodeId from _pytest.nodeid import ItemNodeId from _pytest.nodeid import NodeId -from _pytest.nodeid import OpaqueNodeId from _pytest.reports import BaseReport from _pytest.reports import CollectReport from _pytest.reports import TestReport @@ -95,7 +94,7 @@ def merge_family(left, right) -> None: class _NodeReporter: - def __init__(self, node_id: OpaqueNodeId, xml: LogXML) -> None: + def __init__(self, node_id: ItemNodeId, xml: LogXML) -> None: self.id = node_id self.xml = xml self.add_stats = self.xml.add_stats @@ -459,7 +458,7 @@ def pytest_unconfigure(config: Config) -> None: def mangle_test_address(address: str) -> list[str]: - oid = OpaqueNodeId.parse(address) + oid = ItemNodeId.parse(address) names = [oid.path, *oid.names] # Convert file path to dotted path. names[0] = names[0].replace(nodes.SEP, ".") @@ -493,7 +492,7 @@ def __init__( self.stats: dict[str, int] = dict.fromkeys( ["error", "passed", "failure", "skipped"], 0 ) - self.node_reporters: dict[tuple[OpaqueNodeId, object], _NodeReporter] = {} + self.node_reporters: dict[tuple[ItemNodeId, object], _NodeReporter] = {} self.node_reporters_ordered: list[_NodeReporter] = [] self.global_properties: list[tuple[str, str]] = [] @@ -506,7 +505,7 @@ def __init__( self.family = "xunit1" def finalize(self, report: TestReport) -> None: - node_id = report.id.as_opaque() + node_id = report.id.as_leaf() # Local hack to handle xdist report order. workernode = getattr(report, "node", None) reporter = self.node_reporters.pop((node_id, workernode)) @@ -520,9 +519,9 @@ def finalize(self, report: TestReport) -> None: def node_reporter(self, report: BaseReport | NodeId | str) -> _NodeReporter: match report: case CollectionNodeId() | ItemNodeId() | str(): - node_id = coerce_node_id(report).as_opaque() + node_id = coerce_node_id(report).as_leaf() case BaseReport(): - node_id = report.id.as_opaque() + node_id = report.id.as_leaf() case _: # pragma: no cover assert_never(report) # Local hack to handle xdist report order. @@ -588,7 +587,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: rep for rep in self.open_reports if ( - rep.id.as_opaque() == report.id.as_opaque() + rep.id.as_leaf() == report.id.as_leaf() and getattr(rep, "item_index", None) == report_ii and getattr(rep, "worker_id", None) == report_wid ) @@ -606,7 +605,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: # element for that item (#3850). self.cnt_double_fail_tests += int( ( - report.id.as_opaque(), + report.id.as_leaf(), getattr(report, "node", None), ) in self.node_reporters @@ -635,7 +634,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: rep for rep in self.open_reports if ( - rep.id.as_opaque() == report.id.as_opaque() + rep.id.as_leaf() == report.id.as_leaf() 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 951e4f7ff65..4f2993d85de 100644 --- a/src/_pytest/nodeid.py +++ b/src/_pytest/nodeid.py @@ -10,17 +10,14 @@ - :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. -- :class:`OpaqueNodeId` -- A nodeid reconstructed from an external string - source, rather than from live collection. It recovers the ``::``-separated - ``names`` and the raw ``[params]`` bracket contents (a single opaque - string), but makes no claim to the *internal* structure of the params - string: the ``"-"`` delimiter joining separate ``parametrize()``-call ids - cannot be reliably distinguished from a literal ``"-"`` in a param value, - so :class:`OpaqueNodeId` deliberately leaves ``params`` as one unparsed - string rather than fabricating :class:`ParamId` boundaries. + function). Also serves as the boundary type for nodeids reconstructed + from external strings (on-disk cache files, xdist JSON wire payloads, + duck-typed report-like objects' ``.nodeid`` attributes) -- use + :meth:`ItemNodeId.parse` for that case. It recovers ``names`` and the + raw ``[params]`` bracket contents (a single opaque string); the internal + ``"-"`` delimiter joining separate ``parametrize()``-call ids cannot be + reliably distinguished from a literal ``"-"`` in a param value, so + ``params`` is left as one unparsed string rather than split further. - :data:`NodeId` -- a type alias, ``CollectionNodeId | ItemNodeId``, for code that genuinely needs to accept/hold either kind. @@ -35,34 +32,11 @@ from typing import TYPE_CHECKING from typing import TypeVar -from _pytest.compat import assert_never -from _pytest.scope import Scope - if TYPE_CHECKING: from typing_extensions import Self -@dataclasses.dataclass(frozen=True, slots=True, kw_only=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.CallSpec.param_ids`. - - ``argnames``/``scope`` are only known when built from live collection - 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. - """ - - id: str - argnames: tuple[str, ...] = () - scope: Scope | None = None - - @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) class CollectionNodeId: """Structured address for a ``Collector`` node -- one that can still @@ -83,7 +57,7 @@ class CollectionNodeId: 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()), + # on every __eq__/__hash__ call site elsewhere (e.g. as_leaf()), # so it's worth not repeating the join work on every call. if self._str_cache is not None: return self._str_cache @@ -91,19 +65,37 @@ def __str__(self) -> str: object.__setattr__(self, "_str_cache", s) return s + @classmethod + def parse(cls, nodeid: str) -> CollectionNodeId: + """Parse a nodeid string into a CollectionNodeId. + + Collector nodeids never carry a ``[params]`` bracket, so this is a + straightforward ``::`` split. Used at the string-boundary (e.g. + ``CollectReport._from_json``) to reconstruct a collector id with the + correct concrete type. + """ + path, sep, tail = nodeid.partition("::") + names = tuple(tail.split("::")) if sep else () + self = cls(path=path, names=names) + object.__setattr__(self, "_str_cache", nodeid) + return self + def child(self, name: str) -> CollectionNodeId: """Return a new CollectionNodeId for a child collector node.""" return CollectionNodeId(path=self.path, names=(*self.names, name)) - def leaf(self, name: str, params: tuple[ParamId, ...]) -> ItemNodeId: + def leaf(self, name: str, params: str | None) -> ItemNodeId: """Return a new ItemNodeId for a terminal item node.""" 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 - needs a single, non-structured lookup type (e.g. cache boundaries - that mix live and cache-sourced ids).""" - return OpaqueNodeId.parse(str(self)) + def as_leaf(self) -> ItemNodeId: + """Return the ``ItemNodeId`` form of this id. + + Used by genuinely mixed containers (e.g. :attr:`LFPlugin.lastfailed`, + junitxml ``node_reporters``) that receive both collector and item ids + and need a single comparable key type. + """ + return ItemNodeId.parse(str(self)) @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) @@ -112,82 +104,44 @@ class ItemNodeId: function. Has no ``.child()``/``.leaf()``: nothing ever builds further collection-tree structure on top of an item id. + Also serves as the external-string boundary type (via :meth:`parse`) + for nodeids reconstructed from on-disk cache files, xdist JSON wire + payloads, or duck-typed report-like objects' ``.nodeid`` attributes. + :param path: ``/``-normalized, rootpath-relative filesystem path. :param names: Ordered ``::``-segment names. :param params: - Ordered per-``parametrize()``-call ids. + Raw contents inside the outermost ``[...]`` bracket, kept as one + opaque string (the param-call-boundary structure cannot be + recovered from the ``"-"``-joined flat string). + ``None`` means no bracket at all; ``""`` means an empty ``[]``. """ path: str names: tuple[str, ...] = () - params: tuple[ParamId, ...] = () - _str_cache: str | None = dataclasses.field( - default=None, init=False, repr=False, compare=False - ) - - def __str__(self) -> str: - if self._str_cache is not None: - return self._str_cache - s = "::".join((self.path, *self.names)) - if self.params: - 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 - 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. -NodeId = CollectionNodeId | ItemNodeId - - -@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. - - Unlike :class:`CollectionNodeId`/:class:`ItemNodeId`, this makes no - claim to the *internal* structure of the params string: the ``"-"`` - delimiter is used both to join sub-ids within one ``parametrize()`` call - and to join separate stacked calls, so it cannot be reliably decomposed - back into :class:`ParamId` boundaries. ``params`` is therefore kept as a - single raw string rather than guessing. There is no - ``.child()``/``.leaf()`` -- nothing ever builds further collection-tree - structure on top of a boundary-sourced id. - """ - - path: str - #: ``::``-separated name segments, recovered reliably from the string. - #: Empty tuple when there is no ``::`` in the nodeid. - names: tuple[str, ...] = () - #: Raw contents inside the outermost ``[...]`` bracket, kept as one - #: opaque string (the param-call-boundary structure cannot be recovered). - #: ``None`` means no bracket at all; ``""`` means an empty ``[]``. params: str | None = None _str_cache: str | None = dataclasses.field( default=None, init=False, repr=False, compare=False ) @classmethod - def parse(cls, nodeid: str) -> OpaqueNodeId: - """Parse a nodeid string into its path, names, and raw params.""" + def parse(cls, nodeid: str) -> ItemNodeId: + """Parse a nodeid string into its path, names, and raw params. + + The bracket is peeled off *before* splitting on ``"::"`` so that a + ``"::"`` appearing *inside* a params bracket (e.g. a param value + ``"double::colon"``) is never mistaken for a name separator + (issue #469). + """ path, sep, tail = nodeid.partition("::") if not sep: # No "::" -- everything is the path. Never interpret a bracket # here: file paths can legitimately contain "[" (e.g. test[1].py). self = cls(path=path) else: - # Peel the params bracket off the tail FIRST, so a "::" that - # appears *inside* a params bracket is never mistaken for a name - # separator. + # Peel the params bracket off the tail FIRST. name_part, bracket, param_part = tail.partition("[") names = tuple(name_part.split("::")) params = param_part.removesuffix("]") if bracket else None @@ -210,6 +164,10 @@ def __str__(self) -> str: def rest(self) -> str | None: """Everything after the first ``"::"`` as a single string, or ``None`` when there is no ``"::"`` (i.e. ``names`` is empty). + + This derived property exists so that code which only needs to + re-emit the tail (e.g. ``cwd_relative_nodeid``) can work with a + plain string without caring about the internal structure. """ if not self.names: return None @@ -218,25 +176,34 @@ def rest(self) -> str | None: s += f"[{self.params}]" return s - def as_opaque(self) -> Self: + def as_leaf(self) -> Self: + """Return ``self`` unchanged. + + Exists so that code holding a ``NodeId`` value can call + ``.as_leaf()`` unconditionally to obtain an ``ItemNodeId`` key, + regardless of which concrete type it holds. The collector-side + counterpart (:meth:`CollectionNodeId.as_leaf`) does the actual + conversion. + """ return self +#: Either kind of live-collection node id, for code that genuinely needs to +#: accept/hold both a CollectionNodeId and an ItemNodeId. +NodeId = CollectionNodeId | ItemNodeId + + _N = TypeVar("_N", bound=NodeId) @overload def coerce_node_id(nodeid: _N) -> _N: ... @overload -def coerce_node_id(nodeid: str) -> OpaqueNodeId: ... -def coerce_node_id(nodeid: str | NodeId) -> NodeId | OpaqueNodeId: +def coerce_node_id(nodeid: str) -> ItemNodeId: ... +def coerce_node_id(nodeid: str | NodeId) -> NodeId: """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`.""" - match nodeid: - case CollectionNodeId() | ItemNodeId(): - return nodeid - case str(): - return OpaqueNodeId.parse(nodeid) - case _: # pragma: no cover - assert_never(nodeid) + wrap it in an :class:`ItemNodeId`.""" + if isinstance(nodeid, CollectionNodeId | ItemNodeId): + return nodeid + return ItemNodeId.parse(nodeid) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 03bb9aa3ab7..dd0f3aacd40 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -214,7 +214,7 @@ def __init__( "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, ()) + self._id = self.parent.id.leaf(self.name, None) else: self._id = self.parent.id.child(self.name) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 3f0fbf679e8..324d56fa41b 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -71,7 +71,6 @@ 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 @@ -1172,8 +1171,8 @@ 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 ItemNodeId.params. - _idlist: Sequence[ParamId] = dataclasses.field(default_factory=tuple) + # with "-" they form the item's name `[..]` suffix (see ItemNodeId.params). + _idlist: Sequence[str] = dataclasses.field(default_factory=tuple) # Marks which will be applied to the item. marks: list[Mark] = dataclasses.field(default_factory=list) @@ -1203,10 +1202,7 @@ def setmulti( if id is HIDDEN_PARAM: idlist = self._idlist else: - idlist = [ - *self._idlist, - ParamId(id=id, argnames=argnames, scope=scope), - ] + idlist = [*self._idlist, id] return CallSpec( params=params, indices=indices, @@ -1223,13 +1219,7 @@ def getparam(self, name: str) -> object: @property def id(self) -> str: - 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) + return "-".join(self._idlist) if TYPE_CHECKING: @@ -1709,12 +1699,11 @@ def __init__( ) -> None: # Build the ItemNodeId explicitly from callspec (when parametrized) # instead of going through Node.__init__'s generic - # `parent.id.leaf(name, ())` fallback, which would only see `name` + # `parent.id.leaf(name, None)` 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. + # recover the per-parametrize()-call structure from callspec. base_name = originalname or name - params = callspec.param_ids if callspec is not None else () + params = callspec.id if callspec is not None and callspec._idlist else None node_id = parent.id.leaf(base_name, params) super().__init__(name, parent, config=config, session=session, nodeid=node_id) diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index ab557311695..e8316c84614 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -34,7 +34,6 @@ 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 @@ -72,7 +71,7 @@ class BaseReport: sections: list[tuple[str, str]] outcome: Literal["passed", "failed", "skipped"] - _id: NodeId | OpaqueNodeId + _id: NodeId def __init__(self, **kw: Any) -> None: self.__dict__.update(kw) @@ -83,10 +82,10 @@ def nodeid(self) -> str: @nodeid.setter def nodeid(self, value: str) -> None: - self._id = OpaqueNodeId.parse(value) + self._id = ItemNodeId.parse(value) @property - def id(self) -> NodeId | OpaqueNodeId: + def id(self) -> NodeId: """The structured (non-string) form of ``nodeid``. :meta private: @@ -342,10 +341,10 @@ class TestReport(BaseReport): # xfail reason if xfailed, otherwise not defined. Use hasattr to distinguish. wasxfail: str - _id: ItemNodeId | OpaqueNodeId + _id: ItemNodeId @property - def id(self) -> ItemNodeId | OpaqueNodeId: + def id(self) -> ItemNodeId: """The structured (non-string) form of ``nodeid``. .. note:: @@ -503,10 +502,10 @@ class CollectReport(BaseReport): when = "collect" - _id: CollectionNodeId | OpaqueNodeId + _id: CollectionNodeId @property - def id(self) -> CollectionNodeId | OpaqueNodeId: + def id(self) -> CollectionNodeId: """The structured (non-string) form of ``nodeid``. .. note:: @@ -531,7 +530,11 @@ def __init__( **extra, ) -> None: #: Normalized collection nodeid. - self._id = coerce_node_id(nodeid) + self._id = ( + nodeid + if isinstance(nodeid, CollectionNodeId) + else CollectionNodeId.parse(nodeid) + ) #: Test outcome, always one of "passed", "failed", "skipped". self.outcome = outcome @@ -650,10 +653,10 @@ def serialize_exception_longrepr(rep: BaseReport) -> dict[str, Any]: 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. + # nodeid is a property (backed by self._id) 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 structured id object on the wire. d["nodeid"] = str(d.pop("_id")) if hasattr(report.longrepr, "toterminal"): if hasattr(report.longrepr, "reprtraceback") and hasattr( diff --git a/src/_pytest/stepwise.py b/src/_pytest/stepwise.py index 87a803072dd..582c525a355 100644 --- a/src/_pytest/stepwise.py +++ b/src/_pytest/stepwise.py @@ -11,7 +11,7 @@ from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.main import Session -from _pytest.nodeid import OpaqueNodeId +from _pytest.nodeid import ItemNodeId from _pytest.reports import TestReport @@ -71,7 +71,7 @@ def pytest_sessionfinish(session: Session) -> None: @dataclasses.dataclass class StepwiseCacheInfo: # The nodeid of the last failed test. - last_failed: OpaqueNodeId | None + last_failed: ItemNodeId | 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,9 +114,7 @@ def _load_cached_info(self) -> StepwiseCacheInfo: try: last_failed: str | None = cached_dict["last_failed"] return StepwiseCacheInfo( - OpaqueNodeId.parse(last_failed) - if last_failed is not None - else None, + ItemNodeId.parse(last_failed) if last_failed is not None else None, cached_dict["last_test_count"], cached_dict["last_cache_date_str"], ) @@ -155,7 +153,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.as_opaque() == self.cached_info.last_failed: + if item.id == self.cached_info.last_failed: failed_index = index break @@ -180,13 +178,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.as_opaque() == 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.id.as_opaque() + self.cached_info.last_failed = report.id assert self.session is not None self.session.shouldstop = ( "Test failed, continuing from this test next run." @@ -196,7 +194,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.as_opaque() == 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: diff --git a/src/_pytest/subtests.py b/src/_pytest/subtests.py index 810dee7b19c..299428c6a0b 100644 --- a/src/_pytest/subtests.py +++ b/src/_pytest/subtests.py @@ -33,7 +33,6 @@ 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 @@ -356,9 +355,9 @@ def pytest_report_from_serializable(data: dict[str, Any]) -> SubtestReport | Non return None -# Dict of ItemNodeId/OpaqueNodeId -> number of failed subtests. +# Dict of ItemNodeId -> number of failed subtests. # Used to fail top-level tests that passed but contain failed subtests. -failed_subtests_key = StashKey[defaultdict[ItemNodeId | OpaqueNodeId, int]]() +failed_subtests_key = StashKey[defaultdict[ItemNodeId, int]]() def pytest_configure(config: Config) -> None: diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 8823c691017..97d57efab8b 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -45,7 +45,7 @@ from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config.argparsing import Parser -from _pytest.nodeid import OpaqueNodeId +from _pytest.nodeid import ItemNodeId from _pytest.nodes import Item from _pytest.nodes import Node from _pytest.pathlib import absolutepath @@ -408,8 +408,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[OpaqueNodeId] = set() - self._timing_nodeids_reported: set[OpaqueNodeId] = set() + self._progress_nodeids_reported: set[ItemNodeId] = set() + self._timing_nodeids_reported: set[ItemNodeId] = set() self._show_progress_info = self._determine_show_progress_info() self._collect_report_last_write = timing.Instant() self._already_displayed_warnings: int | None = None @@ -487,7 +487,7 @@ def hasopt(self, char: str) -> bool: return char in self.reportchars def write_fspath_result(self, nodeid: str, res: str, **markup: bool) -> None: - fspath = self.config.rootpath / OpaqueNodeId.parse(nodeid).path + fspath = self.config.rootpath / ItemNodeId.parse(nodeid).path if self.currentfspath is None or fspath != self.currentfspath: if self.currentfspath is not None and self._show_progress_info: self._write_progress_information_filling_space() @@ -661,7 +661,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: markup = {"yellow": True} else: markup = {} - self._progress_nodeids_reported.add(rep.id.as_opaque()) + self._progress_nodeids_reported.add(rep.id.as_leaf()) 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 @@ -754,7 +754,7 @@ def _get_progress_information_message(self) -> str: not_reported = [ r for r in all_reports - if r.id.as_opaque() not in self._timing_nodeids_reported + if r.id.as_leaf() not in self._timing_nodeids_reported ] tests_in_module = sum( i.location[0] == current_location for i in self._session.items @@ -767,7 +767,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.id.as_opaque() for r in not_reported + r.id.as_leaf() for r in not_reported ) return format_node_duration( sum(r.duration for r in not_reported if isinstance(r, TestReport)) @@ -1070,7 +1070,7 @@ def mkrel(nodeid: str) -> str: if fspath: res = mkrel(nodeid) if self.verbosity >= 2 and ( - OpaqueNodeId.parse(nodeid).path != nodes.norm_sep(fspath) + ItemNodeId.parse(nodeid).path != nodes.norm_sep(fspath) ): res += " <- " + bestrelpath(self.startpath, Path(fspath)) else: @@ -1138,7 +1138,7 @@ def collapsed_location_report(reports: list[WarningReport]) -> str: return "\n".join(map(str, locations)) counts_by_filename = Counter( - OpaqueNodeId.parse(str(loc)).path for loc in locations + ItemNodeId.parse(str(loc)).path for loc in locations ) return "\n".join( "{}: {} warning{}".format(k, v, "s" if v > 1 else "") @@ -1182,17 +1182,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.as_opaque()) + self._handle_teardown_sections(rep.id.as_leaf()) - def _get_teardown_reports(self, node_id: OpaqueNodeId) -> list[TestReport]: + def _get_teardown_reports(self, node_id: ItemNodeId) -> list[TestReport]: reports = self.getreports("") return [ report for report in reports - if report.when == "teardown" and report.id.as_opaque() == node_id + if report.when == "teardown" and report.id.as_leaf() == node_id ] - def _handle_teardown_sections(self, node_id: OpaqueNodeId) -> None: + def _handle_teardown_sections(self, node_id: ItemNodeId) -> None: for report in self._get_teardown_reports(node_id): self.print_teardown_sections(report) @@ -1242,7 +1242,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.as_opaque()) + self._handle_teardown_sections(rep.id.as_leaf()) def summary_errors(self) -> None: if self.config.option.tbstyle != "no": @@ -1529,7 +1529,7 @@ def _build_collect_only_summary_stats_line( def _get_node_id_with_markup(tw: TerminalWriter, config: Config, rep: BaseReport): nodeid = config.cwd_relative_nodeid(rep.nodeid) - oid = OpaqueNodeId.parse(nodeid) + oid = ItemNodeId.parse(nodeid) if oid.rest is not None: return f"{oid.path}::{tw.markup(oid.rest, bold=True)}" return oid.path diff --git a/testing/test_nodeid.py b/testing/test_nodeid.py index 2a4b2e4b389..e011c673db8 100644 --- a/testing/test_nodeid.py +++ b/testing/test_nodeid.py @@ -3,23 +3,10 @@ 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.reports import TestReport -from _pytest.scope import Scope import pytest -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(CollectionNodeId(path="")) == "" @@ -42,12 +29,17 @@ def test_child(self) -> None: 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) + leaf = parent.leaf("test_c", "1") assert isinstance(leaf, ItemNodeId) - assert leaf.params == params + assert leaf.params == "1" assert str(leaf) == "a/test_b.py::test_c[1]" + def test_leaf_no_params(self) -> None: + parent = CollectionNodeId(path="a/test_b.py") + leaf = parent.leaf("test_c", None) + assert leaf.params is None + assert str(leaf) == "a/test_b.py::test_c" + 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",)) @@ -59,11 +51,22 @@ def test_eq_and_hash(self) -> None: assert {a: 1}[b] == 1 assert {a, b, c} == {a, c} - def test_as_opaque(self) -> None: + def test_parse(self) -> None: + node_id = CollectionNodeId.parse("a/test_b.py::TestC::test_d") + assert 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_parse_path_only(self) -> None: + node_id = CollectionNodeId.parse("a/b/test_c.py") + assert node_id == CollectionNodeId(path="a/b/test_c.py") + + def test_as_leaf(self) -> None: node_id = CollectionNodeId(path="a/test_b.py", names=("TestC",)) - opaque = node_id.as_opaque() - assert isinstance(opaque, OpaqueNodeId) - assert str(opaque) == str(node_id) + leaf = node_id.as_leaf() + assert isinstance(leaf, ItemNodeId) + assert str(leaf) == str(node_id) class TestItemNodeId: @@ -72,11 +75,7 @@ def test_str_no_params(self) -> None: assert str(node_id) == "a/test_b.py::test_c" def test_str_with_params(self) -> None: - node_id = ItemNodeId( - path="a/test_b.py", - names=("test_c",), - params=(ParamId(id="1"), ParamId(id="x")), - ) + node_id = ItemNodeId(path="a/test_b.py", names=("test_c",), params="1-x") assert str(node_id) == "a/test_b.py::test_c[1-x]" def test_has_no_child_or_leaf(self) -> None: @@ -97,67 +96,57 @@ def test_eq_and_hash(self) -> None: assert {a: 1}[b] == 1 assert {a, b, c} == {a, c} - def test_as_opaque(self) -> None: - node_id = ItemNodeId( - path="a/test_b.py", names=("test_c",), params=(ParamId(id="1"),) - ) - opaque = node_id.as_opaque() - assert isinstance(opaque, OpaqueNodeId) - assert str(opaque) == str(node_id) - - -class TestOpaqueNodeId: - def test_root(self) -> None: - node_id = OpaqueNodeId.parse("") - assert node_id == OpaqueNodeId(path="") - assert str(node_id) == "" + def test_as_leaf_returns_self(self) -> None: + """ItemNodeId.as_leaf() is a no-op: it returns self so that code + holding a NodeId value can call .as_leaf() unconditionally.""" + node_id = ItemNodeId(path="a/test_b.py", names=("test_c",)) + assert node_id.as_leaf() is node_id - def test_path_only(self) -> None: - node_id = OpaqueNodeId.parse("a/b/test_c.py") - assert node_id == OpaqueNodeId(path="a/b/test_c.py") + def test_parse_path_only(self) -> None: + node_id = ItemNodeId.parse("a/b/test_c.py") + assert node_id == ItemNodeId(path="a/b/test_c.py") assert node_id.names == () assert node_id.params is None assert str(node_id) == "a/b/test_c.py" - def test_with_names(self) -> None: - node_id = OpaqueNodeId.parse("a/test_b.py::TestC::test_d") - assert node_id == OpaqueNodeId(path="a/test_b.py", names=("TestC", "test_d")) - assert node_id.names == ("TestC", "test_d") + def test_parse_with_names(self) -> None: + node_id = ItemNodeId.parse("a/test_b.py::TestC::test_d") + assert node_id == ItemNodeId(path="a/test_b.py", names=("TestC", "test_d")) assert node_id.params is None - def test_names_and_params(self) -> None: - node_id = OpaqueNodeId.parse("a/test_b.py::test_c[1-x]") + def test_parse_names_and_params(self) -> None: + node_id = ItemNodeId.parse("a/test_b.py::test_c[1-x]") assert node_id.names == ("test_c",) assert node_id.params == "1-x" assert node_id.rest == "test_c[1-x]" - def test_params_boundary_not_inferred(self) -> None: + def test_parse_params_boundary_not_inferred(self) -> None: """The '-' inside params is used both to join sub-ids within one - parametrize() call and to join separate stacked calls, so the internal - ParamId boundaries cannot be reliably recovered. params is therefore - a single opaque string, not decomposed further.""" - node_id = OpaqueNodeId.parse("a/test_b.py::test_c[a-b-c]") + parametrize() call and to join separate stacked calls, so the + internal call-boundary structure cannot be recovered. params is + therefore a single opaque string, not decomposed further.""" + node_id = ItemNodeId.parse("a/test_b.py::test_c[a-b-c]") assert node_id.params == "a-b-c" - def test_double_colon_inside_params(self) -> None: + def test_parse_double_colon_inside_params(self) -> None: """A '::' inside the [params] bracket must NOT be mistaken for a name separator (issue #469, e.g. a param value 'double::colon').""" - node_id = OpaqueNodeId.parse("a/test_b.py::test_func[double::colon]") + node_id = ItemNodeId.parse("a/test_b.py::test_func[double::colon]") assert node_id.names == ("test_func",) assert node_id.params == "double::colon" assert str(node_id) == "a/test_b.py::test_func[double::colon]" - def test_empty_params(self) -> None: - node_id = OpaqueNodeId.parse("a/test_b.py::test_c[]") + def test_parse_empty_params(self) -> None: + node_id = ItemNodeId.parse("a/test_b.py::test_c[]") assert node_id.names == ("test_c",) assert node_id.params == "" - def test_rest_none_vs_empty_string(self) -> None: + def test_parse_rest_none_vs_empty_string(self) -> None: """None means no "::" was present at all, distinct from "" after a trailing "::" -- both must round-trip losslessly. """ - no_sep = OpaqueNodeId.parse("a/test_b.py") - trailing_sep = OpaqueNodeId.parse("a/test_b.py::") + no_sep = ItemNodeId.parse("a/test_b.py") + trailing_sep = ItemNodeId.parse("a/test_b.py::") assert no_sep.rest is None assert trailing_sep.rest == "" assert str(no_sep) == "a/test_b.py" @@ -177,24 +166,15 @@ def test_rest_none_vs_empty_string(self) -> None: "a/test_b.py::TestC::test_d[a-b]", ], ) - 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") - 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} + def test_parse_round_trip_matches_original_string(self, s: str) -> None: + assert str(ItemNodeId.parse(s)) == s 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", names=("test_c",)) + assert isinstance(node_id, ItemNodeId) + assert node_id == ItemNodeId(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",)) @@ -211,17 +191,8 @@ def test_from_item_node_id_returns_same_object_and_type(self) -> None: assert isinstance(result, ItemNodeId) -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 node_id.as_opaque() is node_id - - class TestWithNodeIdSetter: - def test_nodeid_setter_builds_opaque_node_id(self) -> None: + def test_nodeid_setter_builds_item_node_id(self) -> None: report = TestReport( nodeid="a/test_b.py::test_c", location=("a/test_b.py", 0, "test_c"), @@ -231,5 +202,5 @@ def test_nodeid_setter_builds_opaque_node_id(self) -> None: when="call", ) report.nodeid = "a/test_b.py::test_d" - assert report.id == OpaqueNodeId(path="a/test_b.py", names=("test_d",)) + assert report.id == ItemNodeId(path="a/test_b.py", names=("test_d",)) assert report.nodeid == "a/test_b.py::test_d" From a2c2dbc4e53199bd8f170729bbd74a8bebc8539b Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 5 Aug 2026 10:11:08 -0300 Subject: [PATCH 31/35] Collapse CollectionNodeId into a single NodeId class CollectionNodeId's only job was to enforce at the type level that child()/leaf() can't be called on a parameterised node. After the previous simplifications (ParamId dropped, OpaqueNodeId merged into ItemNodeId), that type distinction had become the source of a new ceremony: because Collector.id returned CollectionNodeId and Item.id returned ItemNodeId, any code holding a NodeId union had to normalise through as_leaf() before comparing or storing. That drove 16 as_leaf() call sites across cacheprovider (x5), junitxml (x6) and terminal (x5). Replace both CollectionNodeId and ItemNodeId with a single NodeId dataclass. child()/leaf() are moved onto NodeId itself with a runtime ValueError guard if self.params is not None (i.e. the id already belongs to a leaf), which gives the same safety guarantee without the static-typing overhead. The two error paths are exercised by new tests. With a uniform NodeId type the as_leaf() ceremony disappears entirely: live collection ids and external-string (cache/xdist/subtest) ids are the same type and compare equal directly. NodeId = CollectionNodeId | ItemNodeId (the old union alias) is gone; NodeId is now the single concrete class. All consumers updated; doc/en/conf.py nitpick_ignore cleaned up; changelog entry revised. Co-Authored-By: Claude Opus 4.8 --- changelog/14758.misc.rst | 2 +- doc/en/conf.py | 3 - src/_pytest/cacheprovider.py | 26 +++-- src/_pytest/config/__init__.py | 6 +- src/_pytest/config/findpaths.py | 4 +- src/_pytest/junitxml.py | 22 ++-- src/_pytest/main.py | 4 +- src/_pytest/nodeid.py | 173 +++++++++++--------------------- src/_pytest/nodes.py | 26 ++--- src/_pytest/python.py | 4 +- src/_pytest/reports.py | 26 ++--- src/_pytest/stepwise.py | 6 +- src/_pytest/subtests.py | 6 +- src/_pytest/terminal.py | 34 +++---- testing/python/metafunc.py | 6 +- testing/test_mark.py | 4 +- testing/test_nodeid.py | 169 +++++++++++++------------------ testing/test_reports.py | 8 +- 18 files changed, 207 insertions(+), 322 deletions(-) diff --git a/changelog/14758.misc.rst b/changelog/14758.misc.rst index 28f4109264f..1def2f5b503 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: ``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. +Internal node ids (the ``::``-separated strings identifying collected items, e.g. ``path/to/test_file.py::TestClass::test_method[param]``) are now represented internally by a single structured :class:`~_pytest.nodeid.NodeId` dataclass instead of being repeatedly re-parsed as plain strings. The public ``nodeid: str`` attribute on nodes and reports is unchanged and remains fully backward compatible for plugins. diff --git a/doc/en/conf.py b/doc/en/conf.py index aa229505a7f..6b83eb13f48 100644 --- a/doc/en/conf.py +++ b/doc/en/conf.py @@ -90,9 +90,6 @@ ("py:class", "_pytest.recwarn.WarningsChecker"), ("py:class", "_pytest.reports.BaseReport"), ("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 b2428d14130..1945b498653 100644 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -29,7 +29,7 @@ from _pytest.fixtures import fixture from _pytest.fixtures import FixtureRequest from _pytest.main import Session -from _pytest.nodeid import ItemNodeId +from _pytest.nodeid import NodeId from _pytest.nodes import Directory from _pytest.nodes import File from _pytest.reports import TestReport @@ -272,7 +272,7 @@ def sort_key(node: nodes.Item | nodes.Collector) -> bool: # Only filter with known failures. if not self._collected_at_least_one_failure: - if not any(x.id.as_leaf() 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" @@ -283,7 +283,7 @@ def sort_key(node: nodes.Item | nodes.Collector) -> bool: result[:] = [ x for x in result - if x.id.as_leaf() in lastfailed + if x.id in lastfailed # Include any passed arguments (not trivial to filter). or session.isinitpath(x.path) # Keep all sub-collectors. @@ -317,8 +317,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[ItemNodeId, bool] = { - ItemNodeId.parse(k): v + self.lastfailed: dict[NodeId, bool] = { + NodeId.parse(k): v for k, v in config.cache.get("cache/lastfailed", {}).items() } self._previously_failed_count: int | None = None @@ -356,14 +356,12 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: def pytest_collectreport(self, report: CollectReport) -> None: passed = report.outcome in ("passed", "skipped") if passed: - report_id = report.id.as_leaf() + report_id = report.id if report_id in self.lastfailed: self.lastfailed.pop(report_id) - self.lastfailed.update( - (item.id.as_leaf(), True) for item in report.result - ) + self.lastfailed.update((item.id, True) for item in report.result) else: - self.lastfailed[report.id.as_leaf()] = True + self.lastfailed[report.id] = True @hookimpl(wrapper=True, tryfirst=True) def pytest_collection_modifyitems( @@ -436,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: set[ItemNodeId] = { - ItemNodeId.parse(s) for s in config.cache.get("cache/nodeids", []) + self.cached_nodeids: set[NodeId] = { + NodeId.parse(s) for s in config.cache.get("cache/nodeids", []) } @hookimpl(wrapper=True, tryfirst=True) @@ -445,8 +443,8 @@ def pytest_collection_modifyitems(self, items: list[nodes.Item]) -> Generator[No res = yield if self.active: - new_items: dict[ItemNodeId, nodes.Item] = {} - other_items: dict[ItemNodeId, nodes.Item] = {} + new_items: dict[NodeId, nodes.Item] = {} + other_items: dict[NodeId, nodes.Item] = {} for item in items: if item.id not in self.cached_nodeids: new_items[item.id] = item diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index d93d8dcc2d1..e5b1e95e636 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -68,7 +68,7 @@ from _pytest.config.argparsing import Parser import _pytest.deprecated import _pytest.hookspec -from _pytest.nodeid import ItemNodeId +from _pytest.nodeid import NodeId from _pytest.outcomes import fail from _pytest.outcomes import Skipped from _pytest.pathlib import absolutepath @@ -647,7 +647,7 @@ def _set_initial_conftests( anchors = [] for initial_path in args: - path = ItemNodeId.parse(str(initial_path)).path + path = NodeId.parse(str(initial_path)).path anchor = absolutepath(invocation_dir / path) # Ensure we do not break if what appears to be an anchor # is in fact a very long option (#10169, #11394). @@ -1330,7 +1330,7 @@ def notify_exception( def cwd_relative_nodeid(self, nodeid: str) -> str: # nodeid's are relative to the rootpath, compute relative to cwd. if self.invocation_params.dir != self.rootpath: - oid = ItemNodeId.parse(nodeid) + oid = NodeId.parse(nodeid) fullpath = self.rootpath / oid.path relative_path = bestrelpath(self.invocation_params.dir, fullpath) nodeid = ( diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index 18d2171c64b..fbd43787f0b 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -13,7 +13,7 @@ import iniconfig from .exceptions import UsageError -from _pytest.nodeid import ItemNodeId +from _pytest.nodeid import NodeId from _pytest.outcomes import fail from _pytest.pathlib import absolutepath from _pytest.pathlib import commonpath @@ -242,7 +242,7 @@ def is_option(x: str) -> bool: return x.startswith("-") def get_file_part_from_node_id(x: str) -> str: - return ItemNodeId.parse(x).path + return NodeId.parse(x).path def get_dir_from_path(path: Path) -> Path: if path.is_dir(): diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index a5a40d86a87..ba57370948a 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -28,8 +28,6 @@ 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.reports import BaseReport from _pytest.reports import CollectReport @@ -94,7 +92,7 @@ def merge_family(left, right) -> None: class _NodeReporter: - def __init__(self, node_id: ItemNodeId, xml: LogXML) -> None: + def __init__(self, node_id: NodeId, xml: LogXML) -> None: self.id = node_id self.xml = xml self.add_stats = self.xml.add_stats @@ -458,7 +456,7 @@ def pytest_unconfigure(config: Config) -> None: def mangle_test_address(address: str) -> list[str]: - oid = ItemNodeId.parse(address) + oid = NodeId.parse(address) names = [oid.path, *oid.names] # Convert file path to dotted path. names[0] = names[0].replace(nodes.SEP, ".") @@ -492,7 +490,7 @@ def __init__( self.stats: dict[str, int] = dict.fromkeys( ["error", "passed", "failure", "skipped"], 0 ) - self.node_reporters: dict[tuple[ItemNodeId, object], _NodeReporter] = {} + self.node_reporters: dict[tuple[NodeId, object], _NodeReporter] = {} self.node_reporters_ordered: list[_NodeReporter] = [] self.global_properties: list[tuple[str, str]] = [] @@ -505,7 +503,7 @@ def __init__( self.family = "xunit1" def finalize(self, report: TestReport) -> None: - node_id = report.id.as_leaf() + node_id = report.id # Local hack to handle xdist report order. workernode = getattr(report, "node", None) reporter = self.node_reporters.pop((node_id, workernode)) @@ -518,10 +516,10 @@ def finalize(self, report: TestReport) -> None: def node_reporter(self, report: BaseReport | NodeId | str) -> _NodeReporter: match report: - case CollectionNodeId() | ItemNodeId() | str(): - node_id = coerce_node_id(report).as_leaf() + case NodeId() | str(): + node_id = coerce_node_id(report) case BaseReport(): - node_id = report.id.as_leaf() + node_id = report.id case _: # pragma: no cover assert_never(report) # Local hack to handle xdist report order. @@ -587,7 +585,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: rep for rep in self.open_reports if ( - rep.id.as_leaf() == report.id.as_leaf() + rep.id == report.id and getattr(rep, "item_index", None) == report_ii and getattr(rep, "worker_id", None) == report_wid ) @@ -605,7 +603,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: # element for that item (#3850). self.cnt_double_fail_tests += int( ( - report.id.as_leaf(), + report.id, getattr(report, "node", None), ) in self.node_reporters @@ -634,7 +632,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: rep for rep in self.open_reports if ( - rep.id.as_leaf() == report.id.as_leaf() + 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/main.py b/src/_pytest/main.py index 57a1174fbd8..36be1c0ff97 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -34,7 +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.nodeid import NodeId from _pytest.outcomes import exit from _pytest.pathlib import absolutepath from _pytest.pathlib import bestrelpath @@ -609,7 +609,7 @@ def __init__(self, config: Config) -> None: parent=None, config=config, session=self, - nodeid=CollectionNodeId(path=""), + nodeid=NodeId(path=""), ) self.testsfailed = 0 self.testscollected = 0 diff --git a/src/_pytest/nodeid.py b/src/_pytest/nodeid.py index 4f2993d85de..f48389a219a 100644 --- a/src/_pytest/nodeid.py +++ b/src/_pytest/nodeid.py @@ -3,120 +3,51 @@ 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 -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). Also serves as the boundary type for nodeids reconstructed +There is a single structured type for nodeids: + +- :class:`NodeId` -- Represents any node in the collection tree, whether a + ``Collector`` or an ``Item`` (test leaf). Collector ids have + ``params=None``; item ids carry the raw ``[params]`` bracket as a string. + ``.child()``/``.leaf()`` build further ids on top of a collector id; both + raise :class:`ValueError` if called on a node that already has params + (i.e., a leaf). Also serves as the boundary type for nodeids reconstructed from external strings (on-disk cache files, xdist JSON wire payloads, duck-typed report-like objects' ``.nodeid`` attributes) -- use - :meth:`ItemNodeId.parse` for that case. It recovers ``names`` and the - raw ``[params]`` bracket contents (a single opaque string); the internal + :meth:`NodeId.parse` for that case. It recovers ``names`` and the raw + ``[params]`` bracket contents (a single opaque string); the internal ``"-"`` delimiter joining separate ``parametrize()``-call ids cannot be reliably distinguished from a literal ``"-"`` in a param value, so ``params`` is left as one unparsed string rather than split further. -- :data:`NodeId` -- a type alias, ``CollectionNodeId | ItemNodeId``, for - code that genuinely needs to accept/hold either kind. The legacy ``::``-joined string form remains available (via ``str(node_id)``) -for backward compatibility with external plugins, for all types. +for backward compatibility with external plugins. """ from __future__ import annotations import dataclasses -from typing import overload -from typing import TYPE_CHECKING -from typing import TypeVar - - -if TYPE_CHECKING: - from typing_extensions import Self @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. - - :param path: - ``/``-normalized, rootpath-relative filesystem path. Empty string - for the session root. - :param names: - Ordered ``::``-segment names. - """ - - path: str - names: tuple[str, ...] = () - _str_cache: str | None = dataclasses.field( - default=None, init=False, repr=False, compare=False - ) - - 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_leaf()), - # 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 - - @classmethod - def parse(cls, nodeid: str) -> CollectionNodeId: - """Parse a nodeid string into a CollectionNodeId. +class NodeId: + """Structured address for a node in the collection tree. - Collector nodeids never carry a ``[params]`` bracket, so this is a - straightforward ``::`` split. Used at the string-boundary (e.g. - ``CollectReport._from_json``) to reconstruct a collector id with the - correct concrete type. - """ - path, sep, tail = nodeid.partition("::") - names = tuple(tail.split("::")) if sep else () - self = cls(path=path, names=names) - object.__setattr__(self, "_str_cache", nodeid) - return self - - def child(self, name: str) -> CollectionNodeId: - """Return a new CollectionNodeId for a child collector node.""" - return CollectionNodeId(path=self.path, names=(*self.names, name)) - - def leaf(self, name: str, params: str | None) -> ItemNodeId: - """Return a new ItemNodeId for a terminal item node.""" - return ItemNodeId(path=self.path, names=(*self.names, name), params=params) - - def as_leaf(self) -> ItemNodeId: - """Return the ``ItemNodeId`` form of this id. - - Used by genuinely mixed containers (e.g. :attr:`LFPlugin.lastfailed`, - junitxml ``node_reporters``) that receive both collector and item ids - and need a single comparable key type. - """ - return ItemNodeId.parse(str(self)) - - -@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. + Collector ids (``Collector`` nodes) have ``params=None`` and can still + have children built under them via :meth:`child` and :meth:`leaf`. - Also serves as the external-string boundary type (via :meth:`parse`) - for nodeids reconstructed from on-disk cache files, xdist JSON wire - payloads, or duck-typed report-like objects' ``.nodeid`` attributes. + Item ids (``Item`` nodes, i.e. test leaves) carry the raw ``[params]`` + bracket as a string and cannot have further children. :param path: - ``/``-normalized, rootpath-relative filesystem path. + ``/``-normalized, rootpath-relative filesystem path. Empty string + for the session root. :param names: - Ordered ``::``-segment names. + Ordered ``::``-segment names after the path. :param params: Raw contents inside the outermost ``[...]`` bracket, kept as one - opaque string (the param-call-boundary structure cannot be - recovered from the ``"-"``-joined flat string). - ``None`` means no bracket at all; ``""`` means an empty ``[]``. + opaque string (the param-call-boundary structure cannot be recovered + from the ``"-"``-joined flat string). + ``None`` means no bracket (a collector id); ``""`` means an empty ``[]``. """ path: str @@ -127,13 +58,16 @@ class ItemNodeId: ) @classmethod - def parse(cls, nodeid: str) -> ItemNodeId: + def parse(cls, nodeid: str) -> NodeId: """Parse a nodeid string into its path, names, and raw params. The bracket is peeled off *before* splitting on ``"::"`` so that a ``"::"`` appearing *inside* a params bracket (e.g. a param value ``"double::colon"``) is never mistaken for a name separator (issue #469). + + Collector nodeids (no ``[params]`` bracket) parse with ``params=None``, + so this works for both collector and item ids. """ path, sep, tail = nodeid.partition("::") if not sep: @@ -176,34 +110,39 @@ def rest(self) -> str | None: s += f"[{self.params}]" return s - def as_leaf(self) -> Self: - """Return ``self`` unchanged. + def child(self, name: str) -> NodeId: + """Return a new :class:`NodeId` for a child collector node. - Exists so that code holding a ``NodeId`` value can call - ``.as_leaf()`` unconditionally to obtain an ``ItemNodeId`` key, - regardless of which concrete type it holds. The collector-side - counterpart (:meth:`CollectionNodeId.as_leaf`) does the actual - conversion. + :raises ValueError: if called on a node that already has params + (i.e., a leaf item) -- only collector ids (``params=None``) can + have children. """ - return self - - -#: Either kind of live-collection node id, for code that genuinely needs to -#: accept/hold both a CollectionNodeId and an ItemNodeId. -NodeId = CollectionNodeId | ItemNodeId - - -_N = TypeVar("_N", bound=NodeId) + if self.params is not None: + raise ValueError( + f"cannot call .child() on a parameterised id {self!r}; " + "only collector ids (params=None) can have children" + ) + return NodeId(path=self.path, names=(*self.names, name)) + + def leaf(self, name: str, params: str | None) -> NodeId: + """Return a new :class:`NodeId` for a terminal item node. + + :raises ValueError: if called on a node that already has params + (i.e., a leaf item) -- only collector ids (``params=None``) can + have children. + """ + if self.params is not None: + raise ValueError( + f"cannot call .leaf() on a parameterised id {self!r}; " + "only collector ids (params=None) can have children" + ) + return NodeId(path=self.path, names=(*self.names, name), params=params) -@overload -def coerce_node_id(nodeid: _N) -> _N: ... -@overload -def coerce_node_id(nodeid: str) -> ItemNodeId: ... def coerce_node_id(nodeid: str | NodeId) -> NodeId: - """Return ``nodeid`` unchanged if already a :data:`NodeId` (live + """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:`ItemNodeId`.""" - if isinstance(nodeid, CollectionNodeId | ItemNodeId): + wrap it in a :class:`NodeId` via :meth:`NodeId.parse`.""" + if isinstance(nodeid, NodeId): return nodeid - return ItemNodeId.parse(nodeid) + return NodeId.parse(nodeid) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index dd0f3aacd40..7b8a72b08c3 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -34,8 +34,6 @@ 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 @@ -198,7 +196,7 @@ def __init__( 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"nodeid must be a NodeId instance " f"or None, got {nodeid!r}. Do not pass nodeid explicitly -- use " f"Node.from_parent() and let pytest compute it automatically." ) @@ -207,9 +205,7 @@ def __init__( if not self.parent: raise TypeError("nodeid or parent must be provided") # 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. + # always leaves and never have children. assert isinstance(self.parent, Collector), ( "Node.parent is always a Collector; an Item can never be a parent" ) @@ -523,16 +519,16 @@ class Collector(Node, abc.ABC): the collection tree. """ - _id: CollectionNodeId + _id: NodeId @property - def id(self) -> CollectionNodeId: + def id(self) -> NodeId: """The structured (non-string) form of ``nodeid``. .. note:: Experimental/internal: the shape of - :class:`~_pytest.nodeid.CollectionNodeId` may change in future + :class:`~_pytest.nodeid.NodeId` may change in future releases. """ return self._id @@ -603,7 +599,7 @@ def __init__( parent: Node | None = None, config: Config | None = None, session: Session | None = None, - nodeid: CollectionNodeId | None = None, + nodeid: NodeId | None = None, ) -> None: if path_or_parent: if isinstance(path_or_parent, Node): @@ -641,7 +637,7 @@ def __init__( if path_str: path_str = norm_sep(path_str) if path_str is not None: - nodeid = CollectionNodeId(path=path_str) + nodeid = NodeId(path=path_str) super().__init__( name=name, @@ -696,16 +692,16 @@ class Item(Node, abc.ABC): Note that for a single function there might be multiple test invocation items. """ - _id: ItemNodeId + _id: NodeId @property - def id(self) -> ItemNodeId: + def id(self) -> NodeId: """The structured (non-string) form of ``nodeid``. .. note:: Experimental/internal: the shape of - :class:`~_pytest.nodeid.ItemNodeId` may change in future + :class:`~_pytest.nodeid.NodeId` may change in future releases. """ return self._id @@ -718,7 +714,7 @@ def __init__( parent=None, config: Config | None = None, session: Session | None = None, - nodeid: ItemNodeId | None = None, + nodeid: 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 324d56fa41b..93c40449ba4 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1171,7 +1171,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 ItemNodeId.params). + # with "-" they form the item's name `[..]` suffix (see NodeId.params). _idlist: Sequence[str] = dataclasses.field(default_factory=tuple) # Marks which will be applied to the item. marks: list[Mark] = dataclasses.field(default_factory=list) @@ -1697,7 +1697,7 @@ def __init__( fixtureinfo: FuncFixtureInfo | None = None, originalname: str | None = None, ) -> None: - # Build the ItemNodeId explicitly from callspec (when parametrized) + # Build the NodeId explicitly from callspec (when parametrized) # instead of going through Node.__init__'s generic # `parent.id.leaf(name, None)` fallback, which would only see `name` # (with any "[params]" suffix already glued on) and couldn't diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index e8316c84614..cc098361a11 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -31,8 +31,6 @@ from _pytest._io import TerminalWriter 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.nodes import Collector from _pytest.nodes import Item @@ -82,7 +80,7 @@ def nodeid(self) -> str: @nodeid.setter def nodeid(self, value: str) -> None: - self._id = ItemNodeId.parse(value) + self._id = NodeId.parse(value) @property def id(self) -> NodeId: @@ -341,23 +339,23 @@ class TestReport(BaseReport): # xfail reason if xfailed, otherwise not defined. Use hasattr to distinguish. wasxfail: str - _id: ItemNodeId + _id: NodeId @property - def id(self) -> ItemNodeId: + def id(self) -> NodeId: """The structured (non-string) form of ``nodeid``. .. note:: Experimental/internal: the shape of - :class:`~_pytest.nodeid.ItemNodeId` may change in future + :class:`~_pytest.nodeid.NodeId` may change in future releases. """ return self._id def __init__( self, - nodeid: str | ItemNodeId, + nodeid: str | NodeId, location: tuple[str, int | None, str], keywords: Mapping[str, Any], outcome: Literal["passed", "failed", "skipped"], @@ -502,23 +500,23 @@ class CollectReport(BaseReport): when = "collect" - _id: CollectionNodeId + _id: NodeId @property - def id(self) -> CollectionNodeId: + def id(self) -> NodeId: """The structured (non-string) form of ``nodeid``. .. note:: Experimental/internal: the shape of - :class:`~_pytest.nodeid.CollectionNodeId` may change in future + :class:`~_pytest.nodeid.NodeId` may change in future releases. """ return self._id def __init__( self, - nodeid: str | CollectionNodeId, + nodeid: str | NodeId, outcome: Literal["passed", "failed", "skipped"], longrepr: ExceptionInfo[BaseException] | tuple[str, int, str] @@ -530,11 +528,7 @@ def __init__( **extra, ) -> None: #: Normalized collection nodeid. - self._id = ( - nodeid - if isinstance(nodeid, CollectionNodeId) - else CollectionNodeId.parse(nodeid) - ) + self._id = nodeid if isinstance(nodeid, NodeId) else NodeId.parse(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 582c525a355..c14b6fc250b 100644 --- a/src/_pytest/stepwise.py +++ b/src/_pytest/stepwise.py @@ -11,7 +11,7 @@ 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 NodeId from _pytest.reports import TestReport @@ -71,7 +71,7 @@ def pytest_sessionfinish(session: Session) -> None: @dataclasses.dataclass class StepwiseCacheInfo: # The nodeid of the last failed test. - last_failed: ItemNodeId | 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 @@ -114,7 +114,7 @@ def _load_cached_info(self) -> StepwiseCacheInfo: try: last_failed: str | None = cached_dict["last_failed"] return StepwiseCacheInfo( - ItemNodeId.parse(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/src/_pytest/subtests.py b/src/_pytest/subtests.py index 299428c6a0b..1a908a15e8f 100644 --- a/src/_pytest/subtests.py +++ b/src/_pytest/subtests.py @@ -32,7 +32,7 @@ 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 NodeId from _pytest.reports import TestReport from _pytest.runner import CallInfo from _pytest.runner import check_interactive_exception @@ -355,9 +355,9 @@ def pytest_report_from_serializable(data: dict[str, Any]) -> SubtestReport | Non return None -# Dict of ItemNodeId -> 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[ItemNodeId, int]]() +failed_subtests_key = StashKey[defaultdict[NodeId, int]]() def pytest_configure(config: Config) -> None: diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 97d57efab8b..aaf4dfb798a 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -45,7 +45,7 @@ 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.nodes import Item from _pytest.nodes import Node from _pytest.pathlib import absolutepath @@ -408,8 +408,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] = set() - self._timing_nodeids_reported: set[ItemNodeId] = 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 @@ -487,7 +487,7 @@ def hasopt(self, char: str) -> bool: return char in self.reportchars def write_fspath_result(self, nodeid: str, res: str, **markup: bool) -> None: - fspath = self.config.rootpath / ItemNodeId.parse(nodeid).path + fspath = self.config.rootpath / NodeId.parse(nodeid).path if self.currentfspath is None or fspath != self.currentfspath: if self.currentfspath is not None and self._show_progress_info: self._write_progress_information_filling_space() @@ -661,7 +661,7 @@ def pytest_runtest_logreport(self, report: TestReport) -> None: markup = {"yellow": True} else: markup = {} - self._progress_nodeids_reported.add(rep.id.as_leaf()) + 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 @@ -752,9 +752,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.id.as_leaf() 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 @@ -766,9 +764,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.id.as_leaf() 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)) ) @@ -1070,7 +1066,7 @@ def mkrel(nodeid: str) -> str: if fspath: res = mkrel(nodeid) if self.verbosity >= 2 and ( - ItemNodeId.parse(nodeid).path != nodes.norm_sep(fspath) + NodeId.parse(nodeid).path != nodes.norm_sep(fspath) ): res += " <- " + bestrelpath(self.startpath, Path(fspath)) else: @@ -1138,7 +1134,7 @@ def collapsed_location_report(reports: list[WarningReport]) -> str: return "\n".join(map(str, locations)) counts_by_filename = Counter( - ItemNodeId.parse(str(loc)).path for loc in locations + NodeId.parse(str(loc)).path for loc in locations ) return "\n".join( "{}: {} warning{}".format(k, v, "s" if v > 1 else "") @@ -1182,17 +1178,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.as_leaf()) + self._handle_teardown_sections(rep.id) - def _get_teardown_reports(self, node_id: ItemNodeId) -> 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.id.as_leaf() == node_id + if report.when == "teardown" and report.id == node_id ] - def _handle_teardown_sections(self, node_id: ItemNodeId) -> None: + def _handle_teardown_sections(self, node_id: NodeId) -> None: for report in self._get_teardown_reports(node_id): self.print_teardown_sections(report) @@ -1242,7 +1238,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.as_leaf()) + self._handle_teardown_sections(rep.id) def summary_errors(self) -> None: if self.config.option.tbstyle != "no": @@ -1529,7 +1525,7 @@ def _build_collect_only_summary_stats_line( def _get_node_id_with_markup(tw: TerminalWriter, config: Config, rep: BaseReport): nodeid = config.cwd_relative_nodeid(rep.nodeid) - oid = ItemNodeId.parse(nodeid) + oid = NodeId.parse(nodeid) if oid.rest is not None: return f"{oid.path}::{tw.markup(oid.rest, bold=True)}" return oid.path diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 1daa8d79202..685ce506663 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -19,7 +19,7 @@ from _pytest import python from _pytest.compat import getfuncargnames from _pytest.compat import NOTSET -from _pytest.nodeid import ItemNodeId +from _pytest.nodeid import NodeId from _pytest.outcomes import fail from _pytest.outcomes import Failed from _pytest.pytester import Pytester @@ -81,13 +81,13 @@ class SessionMock: @dataclasses.dataclass class DefinitionMock(python.FunctionDefinition): - _id: ItemNodeId + _id: NodeId obj: object names = getfuncargnames(func) fixtureinfo: Any = FuncFixtureInfoMock(names) definition: Any = DefinitionMock._create( - obj=func, _id=ItemNodeId(path="mock", names=("nodeid",)) + obj=func, _id=NodeId(path="mock", names=("nodeid",)) ) definition._fixtureinfo = fixtureinfo definition.session = SessionMock(config, FixtureManagerMock({})) diff --git a/testing/test_mark.py b/testing/test_mark.py index f13212ccb92..09df89f9468 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -9,7 +9,7 @@ from _pytest.mark import MarkGenerator from _pytest.mark.structures import _EmptyParameterSetMark from _pytest.mark.structures import EMPTY_PARAMETERSET_OPTION -from _pytest.nodeid import CollectionNodeId +from _pytest.nodeid import NodeId from _pytest.nodes import Collector from _pytest.nodes import Node from _pytest.pytester import Pytester @@ -1149,7 +1149,7 @@ def test_addmarker_order(pytester) -> None: session.own_markers = [] session.parent = None session.nodeid = "" - session.id = CollectionNodeId(path="") + 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 index e011c673db8..f5e05a2f228 100644 --- a/testing/test_nodeid.py +++ b/testing/test_nodeid.py @@ -1,121 +1,101 @@ from __future__ import annotations from _pytest.nodeid import coerce_node_id -from _pytest.nodeid import CollectionNodeId -from _pytest.nodeid import ItemNodeId +from _pytest.nodeid import NodeId from _pytest.reports import TestReport import pytest -class TestCollectionNodeId: +class TestNodeId: + # -- Construction and __str__ -- + def test_str_root(self) -> None: - assert str(CollectionNodeId(path="")) == "" + assert str(NodeId(path="")) == "" def test_str_path_only(self) -> None: - assert str(CollectionNodeId(path="a/b/test_c.py")) == "a/b/test_c.py" + assert str(NodeId(path="a/b/test_c.py")) == "a/b/test_c.py" def test_str_with_names(self) -> None: - node_id = CollectionNodeId(path="a/test_b.py", names=("TestC", "test_d")) + 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="1-x") + assert str(node_id) == "a/test_b.py::test_c[1-x]" + + # -- child() and leaf() -- + def test_child(self) -> None: - parent = CollectionNodeId(path="a/test_b.py") + parent = NodeId(path="a/test_b.py") child = parent.child("TestC") - assert child == CollectionNodeId(path="a/test_b.py", names=("TestC",)) + assert child == NodeId(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") - leaf = parent.leaf("test_c", "1") - assert isinstance(leaf, ItemNodeId) - assert leaf.params == "1" - assert str(leaf) == "a/test_b.py::test_c[1]" + assert grandchild == NodeId(path="a/test_b.py", names=("TestC", "test_d")) def test_leaf_no_params(self) -> None: - parent = CollectionNodeId(path="a/test_b.py") + parent = NodeId(path="a/test_b.py") leaf = parent.leaf("test_c", None) + assert isinstance(leaf, NodeId) assert leaf.params is None assert str(leaf) == "a/test_b.py::test_c" - 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} - - def test_parse(self) -> None: - node_id = CollectionNodeId.parse("a/test_b.py::TestC::test_d") - assert 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_parse_path_only(self) -> None: - node_id = CollectionNodeId.parse("a/b/test_c.py") - assert node_id == CollectionNodeId(path="a/b/test_c.py") - - def test_as_leaf(self) -> None: - node_id = CollectionNodeId(path="a/test_b.py", names=("TestC",)) - leaf = node_id.as_leaf() - assert isinstance(leaf, ItemNodeId) - assert str(leaf) == str(node_id) - + def test_leaf_with_params(self) -> None: + parent = NodeId(path="a/test_b.py") + leaf = parent.leaf("test_c", "1") + assert leaf.params == "1" + assert str(leaf) == "a/test_b.py::test_c[1]" -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_child_raises_on_parameterised_id(self) -> None: + """Cannot build further tree structure on a node that already has + params (i.e., a leaf item).""" + leaf = NodeId(path="a/test_b.py", names=("test_c",), params="x") + with pytest.raises(ValueError, match=r"\.child\(\)"): + leaf.child("more") - def test_str_with_params(self) -> None: - node_id = ItemNodeId(path="a/test_b.py", names=("test_c",), params="1-x") - assert str(node_id) == "a/test_b.py::test_c[1-x]" + def test_leaf_raises_on_parameterised_id(self) -> None: + """Cannot build further tree structure on a node that already has + params (i.e., a leaf item).""" + leaf = NodeId(path="a/test_b.py", names=("test_c",), params="x") + with pytest.raises(ValueError, match=r"\.leaf\(\)"): + leaf.leaf("more", None) - 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") + # -- Equality and hashing -- def test_eq_and_hash(self) -> None: - 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",)) + a = NodeId(path="a/test_b.py", names=("TestC",)) + b = NodeId(path="a/test_b.py", names=("TestC",)) + c = NodeId(path="a/test_b.py", names=("TestD",)) assert a == b assert hash(a) == hash(b) assert a != c assert {a: 1}[b] == 1 assert {a, b, c} == {a, c} - def test_as_leaf_returns_self(self) -> None: - """ItemNodeId.as_leaf() is a no-op: it returns self so that code - holding a NodeId value can call .as_leaf() unconditionally.""" - node_id = ItemNodeId(path="a/test_b.py", names=("test_c",)) - assert node_id.as_leaf() is node_id + def test_params_affects_equality(self) -> None: + no_params = NodeId(path="a/test_b.py", names=("test_c",)) + with_params = NodeId(path="a/test_b.py", names=("test_c",), params="1") + assert no_params != with_params + + # -- parse() -- + + def test_parse_root(self) -> None: + node_id = NodeId.parse("") + assert node_id == NodeId(path="") + assert str(node_id) == "" def test_parse_path_only(self) -> None: - node_id = ItemNodeId.parse("a/b/test_c.py") - assert node_id == ItemNodeId(path="a/b/test_c.py") + node_id = NodeId.parse("a/b/test_c.py") + assert node_id == NodeId(path="a/b/test_c.py") assert node_id.names == () assert node_id.params is None - assert str(node_id) == "a/b/test_c.py" def test_parse_with_names(self) -> None: - node_id = ItemNodeId.parse("a/test_b.py::TestC::test_d") - assert node_id == ItemNodeId(path="a/test_b.py", names=("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")) assert node_id.params is None def test_parse_names_and_params(self) -> None: - node_id = ItemNodeId.parse("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",) assert node_id.params == "1-x" assert node_id.rest == "test_c[1-x]" @@ -125,28 +105,27 @@ def test_parse_params_boundary_not_inferred(self) -> None: parametrize() call and to join separate stacked calls, so the internal call-boundary structure cannot be recovered. params is therefore a single opaque string, not decomposed further.""" - node_id = ItemNodeId.parse("a/test_b.py::test_c[a-b-c]") + node_id = NodeId.parse("a/test_b.py::test_c[a-b-c]") assert node_id.params == "a-b-c" def test_parse_double_colon_inside_params(self) -> None: """A '::' inside the [params] bracket must NOT be mistaken for a name separator (issue #469, e.g. a param value 'double::colon').""" - node_id = ItemNodeId.parse("a/test_b.py::test_func[double::colon]") + node_id = NodeId.parse("a/test_b.py::test_func[double::colon]") assert node_id.names == ("test_func",) assert node_id.params == "double::colon" assert str(node_id) == "a/test_b.py::test_func[double::colon]" def test_parse_empty_params(self) -> None: - node_id = ItemNodeId.parse("a/test_b.py::test_c[]") + node_id = NodeId.parse("a/test_b.py::test_c[]") assert node_id.names == ("test_c",) assert node_id.params == "" def test_parse_rest_none_vs_empty_string(self) -> None: - """None means no "::" was present at all, distinct from "" after a - trailing "::" -- both must round-trip losslessly. - """ - no_sep = ItemNodeId.parse("a/test_b.py") - trailing_sep = ItemNodeId.parse("a/test_b.py::") + """None means no '::' was present at all, distinct from '' after a + trailing '::' -- both must round-trip losslessly.""" + no_sep = NodeId.parse("a/test_b.py") + trailing_sep = NodeId.parse("a/test_b.py::") assert no_sep.rest is None assert trailing_sep.rest == "" assert str(no_sep) == "a/test_b.py" @@ -167,32 +146,22 @@ def test_parse_rest_none_vs_empty_string(self) -> None: ], ) def test_parse_round_trip_matches_original_string(self, s: str) -> None: - assert str(ItemNodeId.parse(s)) == s + assert str(NodeId.parse(s)) == s class TestCoerceNodeId: def test_from_str(self) -> None: node_id = coerce_node_id("a/test_b.py::test_c") - assert isinstance(node_id, ItemNodeId) - assert node_id == ItemNodeId(path="a/test_b.py", names=("test_c",)) + assert isinstance(node_id, NodeId) + assert 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",)) + 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 - 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_item_node_id(self) -> None: + def test_nodeid_setter_builds_node_id(self) -> None: report = TestReport( nodeid="a/test_b.py::test_c", location=("a/test_b.py", 0, "test_c"), @@ -202,5 +171,5 @@ def test_nodeid_setter_builds_item_node_id(self) -> None: when="call", ) report.nodeid = "a/test_b.py::test_d" - assert report.id == ItemNodeId(path="a/test_b.py", names=("test_d",)) + assert report.id == NodeId(path="a/test_b.py", names=("test_d",)) assert report.nodeid == "a/test_b.py::test_d" diff --git a/testing/test_reports.py b/testing/test_reports.py index e79a5a6dd7e..915200d73d5 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -63,11 +63,9 @@ def test_fail(): 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. + # _from_json() reconstructs a NodeId via NodeId.parse() from + # the plain nodeid string on the wire; compare by string form + # to be independent of any cached state on the instance. assert str(a._id) == str(rep._id) continue assert getattr(a, key) == getattr(rep, key) From 17aef400ae26c65449b692a06548a6a1c6ad171d Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 5 Aug 2026 10:55:24 -0300 Subject: [PATCH 32/35] Remove stale type: ignore[misc] from test_nodes.py mypy 2.3+ reports unused type: ignore comments as errors. The diamond-inheritance `class SoWrong(nodes.Item, nodes.File)` comment was no longer needed after upstream changes made this not an error. Co-Authored-By: Claude Opus 4.8 --- testing/test_nodes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/test_nodes.py b/testing/test_nodes.py index 775e28d66e6..e976b9e6f11 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): # type: ignore[misc] + class SoWrong(nodes.Item, nodes.File): def __init__(self, parent: nodes.Collector, path: Path) -> None: super().__init__(name="broken", parent=parent, path=path) From 599f55101775c5d9ce0bf342c150632c3f4b335e Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 5 Aug 2026 11:04:15 -0300 Subject: [PATCH 33/35] Rename NodeId.leaf(name, params) -> child(name).with_params(params) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leaf(name, params) conflated two operations that NodeId already separates elsewhere: child() appends a name segment, with_params() attaches params (terminalizing the id). Separating them makes the API self-documenting: parent.child("test_a").with_params("1-x") # → test_x.py::test_a[1-x] More importantly, this enables the FunctionDefinition collection node work (#14769 / #14805) to be merged without conflict: a FunctionDefinition collector already carries the function name in its names tuple, so Function.__init__ can simply call definition.id.with_params(params) — no name added, correct id produced: definition node test_x.py::test_a definition.with_params() test_x.py::test_a[1] ← correct old leaf(name, p) test_x.py::test_a::test_a[1] ← name doubled The flat layout (no FunctionDefinition) is identical in outcome because child(name).with_params(params) produces the same string as the old leaf(name, params). Production callers updated: nodes.py (generic Item) and python.py (Function). Co-Authored-By: Claude Opus 4.8 --- src/_pytest/nodeid.py | 37 ++++++++++--------------------------- src/_pytest/nodes.py | 2 +- src/_pytest/python.py | 8 ++++---- testing/test_nodeid.py | 27 +++++++++++++-------------- 4 files changed, 28 insertions(+), 46 deletions(-) diff --git a/src/_pytest/nodeid.py b/src/_pytest/nodeid.py index f48389a219a..bfd1882bced 100644 --- a/src/_pytest/nodeid.py +++ b/src/_pytest/nodeid.py @@ -3,21 +3,9 @@ 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 is a single structured type for nodeids: - -- :class:`NodeId` -- Represents any node in the collection tree, whether a - ``Collector`` or an ``Item`` (test leaf). Collector ids have - ``params=None``; item ids carry the raw ``[params]`` bracket as a string. - ``.child()``/``.leaf()`` build further ids on top of a collector id; both - raise :class:`ValueError` if called on a node that already has params - (i.e., a leaf). Also serves as the boundary type for nodeids reconstructed - from external strings (on-disk cache files, xdist JSON wire payloads, - duck-typed report-like objects' ``.nodeid`` attributes) -- use - :meth:`NodeId.parse` for that case. It recovers ``names`` and the raw - ``[params]`` bracket contents (a single opaque string); the internal - ``"-"`` delimiter joining separate ``parametrize()``-call ids cannot be - reliably distinguished from a literal ``"-"`` in a param value, so - ``params`` is left as one unparsed string rather than split further. +The :class:`NodeId` class represents that information in a proper dataclass with the relevant +parts readily available, avoiding reparsing that information when needed and also making for +a better type than `str`. The legacy ``::``-joined string form remains available (via ``str(node_id)``) for backward compatibility with external plugins. @@ -98,10 +86,6 @@ def __str__(self) -> str: def rest(self) -> str | None: """Everything after the first ``"::"`` as a single string, or ``None`` when there is no ``"::"`` (i.e. ``names`` is empty). - - This derived property exists so that code which only needs to - re-emit the tail (e.g. ``cwd_relative_nodeid``) can work with a - plain string without caring about the internal structure. """ if not self.names: return None @@ -124,19 +108,18 @@ def child(self, name: str) -> NodeId: ) return NodeId(path=self.path, names=(*self.names, name)) - def leaf(self, name: str, params: str | None) -> NodeId: - """Return a new :class:`NodeId` for a terminal item node. + def with_params(self, params: str | None) -> NodeId: + """Return a new :class:`NodeId` with ``params`` set. - :raises ValueError: if called on a node that already has params - (i.e., a leaf item) -- only collector ids (``params=None``) can - have children. + :raises ValueError: if ``self`` is already parameterised (i.e. an + item id with ``params is not None``). """ if self.params is not None: raise ValueError( - f"cannot call .leaf() on a parameterised id {self!r}; " - "only collector ids (params=None) can have children" + f"cannot call .with_params() on a parameterised id {self!r}; " + "only collector ids (params=None) can be parameterised" ) - return NodeId(path=self.path, names=(*self.names, name), params=params) + return NodeId(path=self.path, names=self.names, params=params) def coerce_node_id(nodeid: str | NodeId) -> NodeId: diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 7b8a72b08c3..94ba052a6a9 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -210,7 +210,7 @@ def __init__( "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, None) + self._id = self.parent.id.child(self.name).with_params(None) else: self._id = self.parent.id.child(self.name) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 93c40449ba4..bc3d55243f7 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1699,12 +1699,12 @@ def __init__( ) -> None: # Build the NodeId explicitly from callspec (when parametrized) # instead of going through Node.__init__'s generic - # `parent.id.leaf(name, None)` fallback, which would only see `name` - # (with any "[params]" suffix already glued on) and couldn't - # recover the per-parametrize()-call structure from callspec. + # `parent.id.child(name).with_params(None)` fallback, which would + # only see `name` (with any "[params]" suffix already glued on) and + # couldn't recover the per-parametrize()-call structure from callspec. base_name = originalname or name params = callspec.id if callspec is not None and callspec._idlist else None - node_id = parent.id.leaf(base_name, params) + node_id = parent.id.child(base_name).with_params(params) super().__init__(name, parent, config=config, session=session, nodeid=node_id) if callobj is not NOTSET: diff --git a/testing/test_nodeid.py b/testing/test_nodeid.py index f5e05a2f228..c6c545ea206 100644 --- a/testing/test_nodeid.py +++ b/testing/test_nodeid.py @@ -32,18 +32,18 @@ def test_child(self) -> None: grandchild = child.child("test_d") assert grandchild == NodeId(path="a/test_b.py", names=("TestC", "test_d")) - def test_leaf_no_params(self) -> None: + def test_with_params_no_params(self) -> None: parent = NodeId(path="a/test_b.py") - leaf = parent.leaf("test_c", None) - assert isinstance(leaf, NodeId) - assert leaf.params is None - assert str(leaf) == "a/test_b.py::test_c" + item = parent.child("test_c").with_params(None) + assert isinstance(item, NodeId) + assert item.params is None + assert str(item) == "a/test_b.py::test_c" - def test_leaf_with_params(self) -> None: + def test_with_params(self) -> None: parent = NodeId(path="a/test_b.py") - leaf = parent.leaf("test_c", "1") - assert leaf.params == "1" - assert str(leaf) == "a/test_b.py::test_c[1]" + item = parent.child("test_c").with_params("1") + assert item.params == "1" + assert str(item) == "a/test_b.py::test_c[1]" def test_child_raises_on_parameterised_id(self) -> None: """Cannot build further tree structure on a node that already has @@ -52,12 +52,11 @@ def test_child_raises_on_parameterised_id(self) -> None: with pytest.raises(ValueError, match=r"\.child\(\)"): leaf.child("more") - def test_leaf_raises_on_parameterised_id(self) -> None: - """Cannot build further tree structure on a node that already has - params (i.e., a leaf item).""" + def test_with_params_raises_on_parameterised_id(self) -> None: + """Cannot attach params to a node that is already parameterised.""" leaf = NodeId(path="a/test_b.py", names=("test_c",), params="x") - with pytest.raises(ValueError, match=r"\.leaf\(\)"): - leaf.leaf("more", None) + with pytest.raises(ValueError, match=r"\.with_params\(\)"): + leaf.with_params(None) # -- Equality and hashing -- From a995cdb382e234d1f5eac37453d90dc78e84f0c5 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 5 Aug 2026 11:39:02 -0300 Subject: [PATCH 34/35] Tighten Node.parent type to Collector | None The invariant that Node.parent is always a Collector (or None at the session root) was previously asserted at runtime with an explicit isinstance check so mypy could narrow self.parent.id. Encoding it directly in the type annotation makes the invariant declarative and removes the need for the assert + the narrowing comment, letting Node.__init__ read self.parent.id directly. Node.from_parent and FSCollector.__init__ are updated to match. No behaviour changes -- the type change is stricter for static analysis but the runtime set of valid values is identical. Co-Authored-By: Claude Opus 4.8 --- src/_pytest/nodes.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 94ba052a6a9..e5d1f5c343d 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -148,7 +148,7 @@ class Node(abc.ABC, metaclass=NodeMeta): def __init__( self, name: str, - parent: Node | None = None, + parent: Collector | None = None, config: Config | None = None, session: Session | None = None, fspath: None = None, @@ -204,11 +204,6 @@ def __init__( else: if not self.parent: raise TypeError("nodeid or parent must be provided") - # Node.parent is always structurally a Collector -- Items are - # always leaves and never have children. - 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.child(self.name).with_params(None) else: @@ -221,7 +216,7 @@ def __init__( self._store = self.stash @classmethod - def from_parent(cls, parent: Node, **kw) -> Self: + def from_parent(cls, parent: Collector, **kw) -> Self: """Public constructor for Nodes. This indirection got introduced in order to enable removing @@ -596,7 +591,7 @@ def __init__( path_or_parent: Path | Node | None = None, path: Path | None = None, name: str | None = None, - parent: Node | None = None, + parent: Collector | None = None, config: Config | None = None, session: Session | None = None, nodeid: NodeId | None = None, From 4c4c6ccac45741528879227ce560621aad645db0 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 5 Aug 2026 11:44:59 -0300 Subject: [PATCH 35/35] Remove "Experimental/internal" notes from NodeId .id property docstrings NodeId's shape is now stable after the full simplification (single NodeId class, params as str | None). The "Experimental/internal: the shape may change" warnings on Node.id, Collector.id, Item.id, BaseReport.id, TestReport.id and CollectReport.id are no longer warranted. The :meta private: directives are kept as the .id API is still an internal implementation detail not intended for general plugin use. Co-Authored-By: Claude Opus 4.8 --- src/_pytest/nodes.py | 23 ++--------------------- src/_pytest/reports.py | 23 ++--------------------- 2 files changed, 4 insertions(+), 42 deletions(-) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index e5d1f5c343d..7f430f74a2c 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -288,11 +288,6 @@ def id(self) -> NodeId: """The structured (non-string) form of :attr:`nodeid`. :meta private: - - .. note:: - - Experimental/internal: the shape of :class:`~_pytest.nodeid.NodeId` - may change in future releases. """ return self._id @@ -518,14 +513,7 @@ class Collector(Node, abc.ABC): @property def id(self) -> NodeId: - """The structured (non-string) form of ``nodeid``. - - .. note:: - - Experimental/internal: the shape of - :class:`~_pytest.nodeid.NodeId` may change in future - releases. - """ + """The structured (non-string) form of ``nodeid``.""" return self._id class CollectError(Exception): @@ -691,14 +679,7 @@ class Item(Node, abc.ABC): @property def id(self) -> NodeId: - """The structured (non-string) form of ``nodeid``. - - .. note:: - - Experimental/internal: the shape of - :class:`~_pytest.nodeid.NodeId` may change in future - releases. - """ + """The structured (non-string) form of ``nodeid``.""" return self._id nextitem = None diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index cc098361a11..35a212e8eab 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -87,11 +87,6 @@ def id(self) -> NodeId: """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 @@ -343,14 +338,7 @@ class TestReport(BaseReport): @property def id(self) -> NodeId: - """The structured (non-string) form of ``nodeid``. - - .. note:: - - Experimental/internal: the shape of - :class:`~_pytest.nodeid.NodeId` may change in future - releases. - """ + """The structured (non-string) form of ``nodeid``.""" return self._id def __init__( @@ -504,14 +492,7 @@ class CollectReport(BaseReport): @property def id(self) -> NodeId: - """The structured (non-string) form of ``nodeid``. - - .. note:: - - Experimental/internal: the shape of - :class:`~_pytest.nodeid.NodeId` may change in future - releases. - """ + """The structured (non-string) form of ``nodeid``.""" return self._id def __init__(