Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ Harshna
Henk-Jaap Wagenaar
Henry Schreiner
Holger Kohr
host452b
Hugo van Kemenade
Hui Wang (coldnight)
Ian Bicking
Expand Down
1 change: 1 addition & 0 deletions changelog/15045.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed ``--lf`` silently omitting failing custom items whose names contain brackets or ``::``, and ``--sw`` failing to resume at those items.
14 changes: 13 additions & 1 deletion src/_pytest/nodeid.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
87 changes: 87 additions & 0 deletions testing/test_nodeid.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Loading