diff --git a/changelog/14758.misc.rst b/changelog/14758.misc.rst new file mode 100644 index 00000000000..1def2f5b503 --- /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 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 ecb95e0b9f1..6b83eb13f48 100644 --- a/doc/en/conf.py +++ b/doc/en/conf.py @@ -89,6 +89,7 @@ ("py:class", "_pytest.python_api.RaisesContext"), ("py:class", "_pytest.recwarn.WarningsChecker"), ("py:class", "_pytest.reports.BaseReport"), + ("py:class", "_pytest.nodeid.NodeId"), # Sphinx bugs(?) ("py:class", "RewriteHook"), # Undocumented third parties diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 6bcac1ad97a..1945b498653 100644 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -29,6 +29,7 @@ from _pytest.fixtures import fixture from _pytest.fixtures import FixtureRequest from _pytest.main import Session +from _pytest.nodeid import NodeId from _pytest.nodes import Directory from _pytest.nodes import File from _pytest.reports import TestReport @@ -271,7 +272,7 @@ def sort_key(node: nodes.Item | nodes.Collector) -> bool: # Only filter with known failures. if not self._collected_at_least_one_failure: - if not any(x.nodeid in lastfailed for x in result): + if not any(x.id in lastfailed for x in result): return res self.lfplugin.config.pluginmanager.register( LFPluginCollSkipfiles(self.lfplugin), "lfplugin-collskip" @@ -282,7 +283,7 @@ def sort_key(node: nodes.Item | nodes.Collector) -> bool: result[:] = [ x for x in result - if x.nodeid in lastfailed + if x.id in lastfailed # Include any passed arguments (not trivial to filter). or session.isinitpath(x.path) # Keep all sub-collectors. @@ -304,9 +305,7 @@ def pytest_make_collect_report( if collector.path not in self.lfplugin._last_failed_paths: self.lfplugin._skipped_files += 1 - return CollectReport( - collector.nodeid, "passed", longrepr=None, result=[] - ) + return CollectReport(collector.id, "passed", longrepr=None, result=[]) return None @@ -318,7 +317,10 @@ def __init__(self, config: Config) -> None: active_keys = "lf", "failedfirst" self.active = any(config.getoption(key) for key in active_keys) assert config.cache - self.lastfailed: dict[str, bool] = config.cache.get("cache/lastfailed", {}) + self.lastfailed: dict[NodeId, bool] = { + NodeId.parse(k): v + for k, v in config.cache.get("cache/lastfailed", {}).items() + } self._previously_failed_count: int | None = None self._report_status: str | None = None self._skipped_files = 0 # count skipped files during collection due to --lf @@ -335,7 +337,7 @@ def get_last_failed_paths(self) -> set[Path]: rootpath = self.config.rootpath result = set() for nodeid in self.lastfailed: - path = rootpath / nodeid.split("::")[0] + path = rootpath / nodeid.path result.add(path) result.update(path.parents) return {x for x in result if x.exists()} @@ -347,18 +349,19 @@ def pytest_report_collectionfinish(self) -> str | None: def pytest_runtest_logreport(self, report: TestReport) -> None: if (report.when == "call" and report.passed) or report.skipped: - self.lastfailed.pop(report.nodeid, None) + self.lastfailed.pop(report.id, None) elif report.failed: - self.lastfailed[report.nodeid] = True + self.lastfailed[report.id] = True def pytest_collectreport(self, report: CollectReport) -> None: passed = report.outcome in ("passed", "skipped") if passed: - if report.nodeid in self.lastfailed: - self.lastfailed.pop(report.nodeid) - self.lastfailed.update((item.nodeid, True) for item in report.result) + report_id = report.id + if report_id in self.lastfailed: + self.lastfailed.pop(report_id) + self.lastfailed.update((item.id, True) for item in report.result) else: - self.lastfailed[report.nodeid] = True + self.lastfailed[report.id] = True @hookimpl(wrapper=True, tryfirst=True) def pytest_collection_modifyitems( @@ -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,29 @@ def __init__(self, config: Config) -> None: self.config = config self.active = config.option.newfirst assert config.cache is not None - self.cached_nodeids = set(config.cache.get("cache/nodeids", [])) + self.cached_nodeids: set[NodeId] = { + NodeId.parse(s) for s in config.cache.get("cache/nodeids", []) + } @hookimpl(wrapper=True, tryfirst=True) def pytest_collection_modifyitems(self, items: list[nodes.Item]) -> Generator[None]: res = yield if self.active: - new_items: dict[str, nodes.Item] = {} - other_items: dict[str, nodes.Item] = {} + new_items: dict[NodeId, nodes.Item] = {} + other_items: dict[NodeId, nodes.Item] = {} for item in items: - if item.nodeid not in self.cached_nodeids: - new_items[item.nodeid] = item + if item.id not in self.cached_nodeids: + new_items[item.id] = item else: - other_items[item.nodeid] = item + other_items[item.id] = item items[:] = self._get_increasing_order( new_items.values() ) + self._get_increasing_order(other_items.values()) self.cached_nodeids.update(new_items) else: - self.cached_nodeids.update(item.nodeid for item in items) + self.cached_nodeids.update(item.id for item in items) return res @@ -466,7 +472,7 @@ def pytest_sessionfinish(self) -> None: return assert config.cache is not None - config.cache.set("cache/nodeids", sorted(self.cached_nodeids)) + config.cache.set("cache/nodeids", sorted(str(n) for n in self.cached_nodeids)) def pytest_addoption(parser: Parser) -> None: 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 diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index c1a80ed163e..e5b1e95e636 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 NodeId 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 = 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). @@ -1333,12 +1330,12 @@ def notify_exception( def cwd_relative_nodeid(self, nodeid: str) -> str: # nodeid's are relative to the rootpath, compute relative to cwd. if self.invocation_params.dir != self.rootpath: - base_path_part, *nodeid_part = nodeid.split("::") - # Only process path part - fullpath = self.rootpath / base_path_part + oid = NodeId.parse(nodeid) + fullpath = self.rootpath / oid.path relative_path = bestrelpath(self.invocation_params.dir, fullpath) - - nodeid = "::".join([relative_path, *nodeid_part]) + nodeid = ( + relative_path if oid.rest is None else f"{relative_path}::{oid.rest}" + ) return nodeid @classmethod diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index 2a4bed319a9..fbd43787f0b 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 NodeId 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 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 4dab8f0fb03..ba57370948a 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -22,10 +22,15 @@ 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 NodeId +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 @@ -87,8 +92,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 @@ -116,19 +121,21 @@ def make_properties_node(self) -> ET.Element | None: return properties return None - def record_testreport(self, testreport: TestReport) -> None: + def record_testreport(self, testreport: TestReport | CollectReport) -> None: names = mangle_test_address(testreport.nodeid) existing_attrs = self.attrs classnames = names[:-1] if self.xml.prefix: classnames.insert(0, self.xml.prefix) + location = testreport.location + assert location is not None attrs: dict[str, str] = { "classname": ".".join(classnames), "name": bin_xml_escape(names[-1]), - "file": testreport.location[0], + "file": location[0], } - if testreport.location[1] is not None: - attrs["line"] = str(testreport.location[1]) + if location[1] is not None: + attrs["line"] = str(location[1]) if hasattr(testreport, "url"): attrs["url"] = testreport.url self.attrs = attrs @@ -209,12 +216,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: @@ -322,7 +329,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 @@ -449,13 +456,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 = NodeId.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 @@ -481,7 +490,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 +503,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 +514,24 @@ 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: + match report: + case NodeId() | str(): + node_id = coerce_node_id(report) + case BaseReport(): + node_id = report.id + case _: # pragma: no cover + assert_never(report) # 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) @@ -527,7 +542,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 @@ -570,7 +585,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 +603,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 +632,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 ) @@ -634,7 +649,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/main.py b/src/_pytest/main.py index 1b337e20c7e..36be1c0ff97 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 NodeId 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=NodeId(path=""), ) self.testsfailed = 0 self.testscollected = 0 diff --git a/src/_pytest/nodeid.py b/src/_pytest/nodeid.py new file mode 100644 index 00000000000..bfd1882bced --- /dev/null +++ b/src/_pytest/nodeid.py @@ -0,0 +1,131 @@ +"""Structured representation of a pytest "nodeid". + +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]``. + +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. +""" + +from __future__ import annotations + +import dataclasses + + +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class NodeId: + """Structured address for a node in the collection tree. + + Collector ids (``Collector`` nodes) have ``params=None`` and can still + have children built under them via :meth:`child` and :meth:`leaf`. + + 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. Empty string + for the session root. + :param 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 (a collector id); ``""`` means an empty ``[]``. + """ + + path: str + names: tuple[str, ...] = () + params: str | None = None + _str_cache: str | None = dataclasses.field( + default=None, init=False, repr=False, compare=False + ) + + @classmethod + 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: + # 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. + 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) + return self + + def __str__(self) -> str: + if self._str_cache is not None: + return self._str_cache + 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 child(self, name: str) -> NodeId: + """Return a new :class:`NodeId` for a child collector 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 .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 with_params(self, params: str | None) -> NodeId: + """Return a new :class:`NodeId` with ``params`` set. + + :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 .with_params() on a parameterised id {self!r}; " + "only collector ids (params=None) can be parameterised" + ) + return NodeId(path=self.path, names=self.names, params=params) + + +def coerce_node_id(nodeid: str | NodeId) -> NodeId: + """Return ``nodeid`` unchanged if already a :class:`NodeId` (live + collection data); otherwise treat it as an external nodeid string and + wrap it in a :class:`NodeId` via :meth:`NodeId.parse`.""" + if isinstance(nodeid, NodeId): + return nodeid + return NodeId.parse(nodeid) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index f0629c2daf7..7f430f74a2c 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -34,6 +34,7 @@ from _pytest.mark.structures import Mark from _pytest.mark.structures import MarkDecorator from _pytest.mark.structures import NodeKeywords +from _pytest.nodeid import NodeId from _pytest.outcomes import fail from _pytest.pathlib import absolutepath from _pytest.stash import Stash @@ -135,7 +136,7 @@ class Node(abc.ABC, metaclass=NodeMeta): # Note that __dict__ is still available. __slots__ = ( "__dict__", - "_nodeid", + "_id", "_store", "config", "name", @@ -147,12 +148,12 @@ 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, path: Path | None = None, - nodeid: str | None = None, + nodeid: NodeId | None = None, ) -> None: #: A unique name within the scope of the parent node. self.name: str = name @@ -193,12 +194,20 @@ def __init__( self.extra_keyword_matches: set[str] = set() if nodeid is not None: - assert "::()" not in nodeid - self._nodeid = nodeid + if not isinstance(nodeid, NodeId): # pragma: no cover + raise ValueError( + 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." + ) + self._id = nodeid else: if not self.parent: raise TypeError("nodeid or parent must be provided") - self._nodeid = self.parent.nodeid + "::" + self.name + if isinstance(self, Item): + self._id = self.parent.id.child(self.name).with_params(None) + else: + self._id = self.parent.id.child(self.name) #: A place where plugins can store information on the node for their #: own use. @@ -207,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 @@ -272,10 +281,15 @@ 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: + """The structured (non-string) form of :attr:`nodeid`. - def __hash__(self) -> int: - return hash(self._nodeid) + :meta private: + """ + return self._id def setup(self) -> None: pass @@ -495,6 +509,13 @@ class Collector(Node, abc.ABC): the collection tree. """ + _id: NodeId + + @property + def id(self) -> NodeId: + """The structured (non-string) form of ``nodeid``.""" + return self._id + class CollectError(Exception): """An error during collection, contains a custom message.""" @@ -558,10 +579,10 @@ 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: str | None = None, + nodeid: NodeId | None = None, ) -> None: if path_or_parent: if isinstance(path_or_parent, Node): @@ -590,12 +611,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 = NodeId(path=path_str) super().__init__( name=name, @@ -650,6 +675,13 @@ class Item(Node, abc.ABC): Note that for a single function there might be multiple test invocation items. """ + _id: NodeId + + @property + def id(self) -> NodeId: + """The structured (non-string) form of ``nodeid``.""" + return self._id + nextitem = None def __init__( @@ -658,7 +690,7 @@ def __init__( parent=None, config: Config | None = None, session: Session | None = None, - nodeid: str | None = None, + nodeid: NodeId | None = None, **kw, ) -> None: # The first two arguments are intentionally passed positionally, 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/src/_pytest/python.py b/src/_pytest/python.py index be0ea5b4d05..bc3d55243f7 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1170,7 +1170,8 @@ 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 "-". + # One entry per (possibly stacked) parametrize() call, in order. Joined + # 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) @@ -1186,6 +1187,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 +1199,15 @@ def setmulti( params[arg] = val indices[arg] = param_index arg2scope[arg] = scope + if id is HIDDEN_PARAM: + idlist = self._idlist + else: + idlist = [*self._idlist, id] 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)], ) @@ -1691,7 +1697,15 @@ 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).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.child(base_name).with_params(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..35a212e8eab 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.config import Config +from _pytest.nodeid import coerce_node_id +from _pytest.nodeid import NodeId from _pytest.nodes import Collector from _pytest.nodes import Item from _pytest.outcomes import fail @@ -65,12 +67,29 @@ 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 + 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 = NodeId.parse(value) + + @property + def id(self) -> NodeId: + """The structured (non-string) form of ``nodeid``. + + :meta private: + """ + return self._id + if TYPE_CHECKING: # Can have arbitrary fields given to __init__(). def __getattr__(self, key: str) -> Any: ... @@ -162,7 +181,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: @@ -315,9 +334,16 @@ class TestReport(BaseReport): # xfail reason if xfailed, otherwise not defined. Use hasattr to distinguish. wasxfail: str + _id: NodeId + + @property + def id(self) -> NodeId: + """The structured (non-string) form of ``nodeid``.""" + return self._id + 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 +361,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 +465,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, @@ -462,9 +488,16 @@ class CollectReport(BaseReport): when = "collect" + _id: NodeId + + @property + def id(self) -> NodeId: + """The structured (non-string) form of ``nodeid``.""" + return self._id + def __init__( self, - nodeid: str, + nodeid: str | NodeId, outcome: Literal["passed", "failed", "skipped"], longrepr: ExceptionInfo[BaseException] | tuple[str, int, str] @@ -476,7 +509,7 @@ def __init__( **extra, ) -> None: #: Normalized collection nodeid. - self.nodeid = nodeid + self._id = nodeid if isinstance(nodeid, NodeId) else NodeId.parse(nodeid) #: Test outcome, always one of "passed", "failed", "skipped". self.outcome = outcome @@ -594,6 +627,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) 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( 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..c14b6fc250b 100644 --- a/src/_pytest/stepwise.py +++ b/src/_pytest/stepwise.py @@ -11,6 +11,7 @@ from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.main import Session +from _pytest.nodeid import NodeId from _pytest.reports import TestReport @@ -70,7 +71,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 +112,9 @@ 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"], + NodeId.parse(last_failed) if last_failed is not None else None, cached_dict["last_test_count"], cached_dict["last_cache_date_str"], ) @@ -151,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.nodeid == self.cached_info.last_failed: + if item.id == self.cached_info.last_failed: failed_index = index break @@ -176,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.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 +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.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 +208,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..1a908a15e8f 100644 --- a/src/_pytest/subtests.py +++ b/src/_pytest/subtests.py @@ -32,6 +32,7 @@ from _pytest.logging import catching_logs from _pytest.logging import LogCaptureHandler from _pytest.logging import LoggingPlugin +from _pytest.nodeid import NodeId from _pytest.reports import TestReport from _pytest.runner import CallInfo from _pytest.runner import check_interactive_exception @@ -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..aaf4dfb798a 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -45,6 +45,7 @@ from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config.argparsing import Parser +from _pytest.nodeid import NodeId from _pytest.nodes import Item from _pytest.nodes import Node from _pytest.pathlib import absolutepath @@ -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 @@ -486,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 / 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() @@ -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: @@ -1065,7 +1066,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) + NodeId.parse(nodeid).path != nodes.norm_sep(fspath) ): res += " <- " + bestrelpath(self.startpath, Path(fspath)) else: @@ -1133,7 +1134,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 + NodeId.parse(str(loc)).path for loc in locations ) return "\n".join( "{}: {} warning{}".format(k, v, "s" if v > 1 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": @@ -1524,12 +1525,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 = NodeId.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: diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 566071d47a1..685ce506663 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -19,6 +19,7 @@ from _pytest import python from _pytest.compat import getfuncargnames from _pytest.compat import NOTSET +from _pytest.nodeid import NodeId from _pytest.outcomes import fail from _pytest.outcomes import Failed from _pytest.pytester import Pytester @@ -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_junitxml.py b/testing/test_junitxml.py index bee1cf2fc75..3b51495ac6b 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 @@ -1269,18 +1270,18 @@ 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() 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) @@ -1580,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") @@ -1618,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) diff --git a/testing/test_mark.py b/testing/test_mark.py index c70376e7015..09df89f9468 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -9,6 +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 NodeId from _pytest.nodes import Collector from _pytest.nodes import Node from _pytest.pytester import Pytester @@ -1144,10 +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.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..c6c545ea206 --- /dev/null +++ b/testing/test_nodeid.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from _pytest.nodeid import coerce_node_id +from _pytest.nodeid import NodeId +from _pytest.reports import TestReport +import pytest + + +class TestNodeId: + # -- Construction and __str__ -- + + 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="1-x") + assert str(node_id) == "a/test_b.py::test_c[1-x]" + + # -- child() and leaf() -- + + 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_with_params_no_params(self) -> None: + parent = NodeId(path="a/test_b.py") + 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_with_params(self) -> None: + parent = NodeId(path="a/test_b.py") + 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 + 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_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"\.with_params\(\)"): + leaf.with_params(None) + + # -- Equality and hashing -- + + def test_eq_and_hash(self) -> None: + 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_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 = 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 + + def test_parse_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")) + assert node_id.params is None + + def test_parse_names_and_params(self) -> None: + 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]" + + 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 call-boundary structure cannot be recovered. params is + therefore a single opaque string, not decomposed further.""" + 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 = 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 = 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 = 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" + assert str(trailing_sep) == "a/test_b.py::" + assert no_sep != trailing_sep + + @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]", + "a/test_b.py::test_func[double::colon]", + "a/test_b.py::TestC::test_d[a-b]", + ], + ) + def test_parse_round_trip_matches_original_string(self, s: str) -> None: + 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, NodeId) + 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 coerce_node_id(node_id) is node_id + + +class TestWithNodeIdSetter: + 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"), + keywords={}, + outcome="passed", + longrepr=None, + when="call", + ) + report.nodeid = "a/test_b.py::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 23c5968bb52..915200d73d5 100644 --- a/testing/test_reports.py +++ b/testing/test_reports.py @@ -60,8 +60,15 @@ 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() 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) assert rep.longrepr.reprcrash is not None assert a.longrepr.reprcrash is not None assert rep.longrepr.reprcrash.lineno == a.longrepr.reprcrash.lineno @@ -76,6 +83,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"] == "test_to_json_nodeid_wire_shape.py::test_a" + assert d["nodeid"] == rep.nodeid + 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