From b59f4222f2fffff8eeeccef5de4cb8d9bbeed752 Mon Sep 17 00:00:00 2001 From: host452b <32806348+host452b@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:19:23 +0000 Subject: [PATCH] Preserve NodeId identity across string serialization Custom item names can contain delimiters that parse() interprets as structured fields. Compare and hash by the public nodeid string so lastfailed and stepwise can recognize cached custom items. Fixes #15045. Co-authored-by: OpenAI Codex --- AUTHORS | 1 + changelog/15045.bugfix.rst | 1 + src/_pytest/nodeid.py | 14 +++++- testing/test_nodeid.py | 87 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 changelog/15045.bugfix.rst diff --git a/AUTHORS b/AUTHORS index ad4c2093892..4cf16bf36c8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -210,6 +210,7 @@ Harshna Henk-Jaap Wagenaar Henry Schreiner Holger Kohr +host452b Hugo van Kemenade Hui Wang (coldnight) Ian Bicking diff --git a/changelog/15045.bugfix.rst b/changelog/15045.bugfix.rst new file mode 100644 index 00000000000..0d595efc4a9 --- /dev/null +++ b/changelog/15045.bugfix.rst @@ -0,0 +1 @@ +Fixed ``--lf`` silently omitting failing custom items whose names contain brackets or ``::``, and ``--sw`` failing to resume at those items. diff --git a/src/_pytest/nodeid.py b/src/_pytest/nodeid.py index f859b153475..cee4ac9db8d 100644 --- a/src/_pytest/nodeid.py +++ b/src/_pytest/nodeid.py @@ -15,10 +15,14 @@ import dataclasses -@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True, eq=False) class NodeId: """Structured identifier of a node in the collection tree. + Equality and hashing use the string form, preserving identity across cache + and report serialization. Custom collector names can contain delimiters such + as ``[`` or ``::``, so parsing cannot always recover the original fields. + :param path: ``/``-normalized, rootpath-relative filesystem path. Empty string for the session root. @@ -65,6 +69,14 @@ def __str__(self) -> str: object.__setattr__(self, "_str_cache", s) return s + def __eq__(self, other: object) -> bool: + if not isinstance(other, NodeId): + return NotImplemented + return str(self) == str(other) + + def __hash__(self) -> int: + return hash(str(self)) + @property def rest(self) -> str | None: """Everything after the first ``"::"`` as a single string, or diff --git a/testing/test_nodeid.py b/testing/test_nodeid.py index c6c545ea206..47f88954f80 100644 --- a/testing/test_nodeid.py +++ b/testing/test_nodeid.py @@ -1,7 +1,11 @@ from __future__ import annotations +import json + +from _pytest.config import ExitCode from _pytest.nodeid import coerce_node_id from _pytest.nodeid import NodeId +from _pytest.pytester import Pytester from _pytest.reports import TestReport import pytest @@ -75,6 +79,41 @@ def test_params_affects_equality(self) -> None: with_params = NodeId(path="a/test_b.py", names=("test_c",), params="1") assert no_params != with_params + @pytest.mark.parametrize( + "node_id", + [ + NodeId(path="test[1].json", names=("test[x]",)), + NodeId(path="test.json", names=("test[]",)), + NodeId(path="test.json", names=("test[",)), + NodeId(path="test.json", names=("test[x]suffix",)), + NodeId(path="test.json", names=("test::name",)), + NodeId(path="test.json", names=("Group[x]", "test")), + NodeId(path="test.json", names=("Group[x]", "test"), params="a::b"), + ], + ) + def test_identity_survives_string_round_trip(self, node_id: NodeId) -> None: + restored = NodeId.parse(str(node_id)) + assert node_id == restored + assert restored == node_id + assert hash(node_id) == hash(restored) + assert {node_id: True}[restored] + assert {restored: True}[node_id] + assert len({node_id, restored}) == 1 + + def test_distinct_strings_with_same_parsed_fields(self) -> None: + # Parsing accepts an unclosed bracket and preserves the original text. + unclosed = NodeId.parse("test.json::test[x") + closed = NodeId.parse("test.json::test[x]") + assert unclosed.names == closed.names + assert unclosed.params == closed.params + assert unclosed != closed + assert len({unclosed, closed}) == 2 + + def test_equality_with_other_types(self) -> None: + node_id = NodeId(path="test.json", names=("test",)) + assert node_id.__eq__(str(node_id)) is NotImplemented + assert node_id != str(node_id) + # -- parse() -- def test_parse_root(self) -> None: @@ -172,3 +211,51 @@ def test_nodeid_setter_builds_node_id(self) -> None: 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" + + +@pytest.mark.parametrize("name", ["b_bad[one]", "b_bad[]", "b_bad[", "b_bad::one"]) +@pytest.mark.parametrize("mode", ["--lf", "--sw"]) +def test_cached_custom_item_names(pytester: Pytester, name: str, mode: str) -> None: + pytester.makeconftest( + """ + import json + import pytest + + def pytest_collect_file(parent, file_path): + if file_path.name == "test_cases.json": + return Cases.from_parent(parent, path=file_path) + + class Cases(pytest.File): + def collect(self): + for name, passed in json.loads(self.path.read_text()).items(): + yield Case.from_parent(self, name=name, passed=passed) + + class Case(pytest.Item): + def __init__(self, *, passed, **kwargs): + super().__init__(**kwargs) + self.passed = passed + + def runtest(self): + assert self.passed + """ + ) + cases = {"a_pass": True, name: False, "c_fixed": False} + path = pytester.makefile(".json", test_cases=json.dumps(cases)) + result = pytester.runpytest_subprocess(mode, "--tb=no") + if mode == "--lf": + result.assert_outcomes(failed=2, passed=1) + assert result.ret == ExitCode.TESTS_FAILED + else: + result.assert_outcomes(failed=1, passed=1) + assert result.ret == ExitCode.INTERRUPTED + + # Fix only the ordinary item. The custom-named failure must still be found. + cases["c_fixed"] = True + path.write_text(json.dumps(cases), encoding="utf-8") + result = pytester.runpytest_subprocess(mode, "--tb=no") + if mode == "--lf": + result.assert_outcomes(failed=1, passed=1) + assert result.ret == ExitCode.TESTS_FAILED + else: + result.assert_outcomes(failed=1, deselected=1) + assert result.ret == ExitCode.INTERRUPTED