From 3e2a73bbd7373dcb7ce4723693e777d4ec89d707 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 13 Nov 2025 19:27:26 -0300 Subject: [PATCH 1/4] Ensure subtest's context kwargs are JSON serializable Convert all the values of `SubtestContext.kwargs` to strings using `saferepr`. This complies with the requirement that the returned dict from `pytest_report_to_serializable` is serializable to JSON, at the cost of losing type information for objects that are natively supported by JSON. Fixes pytest-dev/pytest-xdist#1273 --- changelog/13963.bugfix.rst | 3 +++ src/_pytest/subtests.py | 5 ++++- testing/test_subtests.py | 38 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 changelog/13963.bugfix.rst diff --git a/changelog/13963.bugfix.rst b/changelog/13963.bugfix.rst new file mode 100644 index 00000000000..90cb5ae6315 --- /dev/null +++ b/changelog/13963.bugfix.rst @@ -0,0 +1,3 @@ +Fixed subtests running with `pytest-xdist `__ when their contexts contain non-standard objects. + +Fixes `pytest-dev/pytest-xdist#1273 `__. diff --git a/src/_pytest/subtests.py b/src/_pytest/subtests.py index a96b11f1fe4..f66d962a4dd 100644 --- a/src/_pytest/subtests.py +++ b/src/_pytest/subtests.py @@ -62,7 +62,10 @@ class SubtestContext: kwargs: Mapping[str, Any] def _to_json(self) -> dict[str, Any]: - return dataclasses.asdict(self) + result = dataclasses.asdict(self) + # Brute-force the returned kwargs dict to be JSON serializable (pytest-dev/pytest-xdist#1273). + result["kwargs"] = {k: saferepr(v) for (k, v) in result["kwargs"].items()} + return result @classmethod def _from_json(cls, d: dict[str, Any]) -> Self: diff --git a/testing/test_subtests.py b/testing/test_subtests.py index 6849df53622..8af48d00d02 100644 --- a/testing/test_subtests.py +++ b/testing/test_subtests.py @@ -1,8 +1,10 @@ from __future__ import annotations +from enum import Enum import sys from typing import Literal +from _pytest._io.saferepr import saferepr from _pytest.subtests import SubtestContext from _pytest.subtests import SubtestReport import pytest @@ -958,9 +960,13 @@ def test(subtests): def test_serialization() -> None: + """Ensure subtest's kwargs are serialized using `saferepr` (pytest-dev/pytest-xdist#1273).""" from _pytest.subtests import pytest_report_from_serializable from _pytest.subtests import pytest_report_to_serializable + class MyEnum(Enum): + A = "A" + report = SubtestReport( "test_foo::test_foo", ("test_foo.py", 12, ""), @@ -968,10 +974,38 @@ def test_serialization() -> None: outcome="passed", when="call", longrepr=None, - context=SubtestContext(msg="custom message", kwargs=dict(i=10)), + context=SubtestContext(msg="custom message", kwargs=dict(i=10, a=MyEnum.A)), ) data = pytest_report_to_serializable(report) assert data is not None new_report = pytest_report_from_serializable(data) assert new_report is not None - assert new_report.context == SubtestContext(msg="custom message", kwargs=dict(i=10)) + assert new_report.context == SubtestContext( + msg="custom message", kwargs=dict(i=saferepr(10), a=saferepr(MyEnum.A)) + ) + + +def test_serialization_xdist(pytester: pytest.Pytester) -> None: + """Regression test for pytest-dev/pytest-xdist#1273.""" + pytest.importorskip("xdist") + pytester.makepyfile( + """ + from enum import Enum + import unittest + + class MyEnum(Enum): + A = "A" + + def test(subtests): + with subtests.test(a=MyEnum.A): + pass + + class T(unittest.TestCase): + + def test(self): + with self.subTest(a=MyEnum.A): + pass + """ + ) + result = pytester.runpytest("-n1", "-pxdist.plugin") + result.assert_outcomes(passed=2) From 70a03011a55f77f6cbc3c3f31d3e9b18365eddd5 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 22 Nov 2025 09:18:07 -0300 Subject: [PATCH 2/4] Best-effort to convert to JSON --- changelog/13963.bugfix.rst | 2 +- src/_pytest/subtests.py | 16 ++++++++++++++-- testing/test_subtests.py | 7 +++++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/changelog/13963.bugfix.rst b/changelog/13963.bugfix.rst index 90cb5ae6315..a5f7ebe5c03 100644 --- a/changelog/13963.bugfix.rst +++ b/changelog/13963.bugfix.rst @@ -1,3 +1,3 @@ -Fixed subtests running with `pytest-xdist `__ when their contexts contain non-standard objects. +Fixed subtests running with `pytest-xdist `__ when their contexts contain objects that are not JSON-serializable. Fixes `pytest-dev/pytest-xdist#1273 `__. diff --git a/src/_pytest/subtests.py b/src/_pytest/subtests.py index f66d962a4dd..fbd01e71d1d 100644 --- a/src/_pytest/subtests.py +++ b/src/_pytest/subtests.py @@ -11,6 +11,7 @@ from contextlib import ExitStack from contextlib import nullcontext import dataclasses +import json import time from types import TracebackType from typing import Any @@ -63,8 +64,19 @@ class SubtestContext: def _to_json(self) -> dict[str, Any]: result = dataclasses.asdict(self) - # Brute-force the returned kwargs dict to be JSON serializable (pytest-dev/pytest-xdist#1273). - result["kwargs"] = {k: saferepr(v) for (k, v) in result["kwargs"].items()} + + # Best-effort to convert the kwargs values to JSON (pytest-dev/pytest-xdist#1273). + # If they can be converted, we return as it is, otherwise we return its saferepr because it seems + # this is the best we can do at this point. + def convert(x: Any) -> Any: + try: + json.dumps(x) + except TypeError: + return saferepr(x) + else: + return x + + result["kwargs"] = {k: convert(v) for (k, v) in result["kwargs"].items()} return result @classmethod diff --git a/testing/test_subtests.py b/testing/test_subtests.py index 8af48d00d02..fbd9cc7e6ef 100644 --- a/testing/test_subtests.py +++ b/testing/test_subtests.py @@ -1,6 +1,7 @@ from __future__ import annotations from enum import Enum +import json import sys from typing import Literal @@ -978,14 +979,16 @@ class MyEnum(Enum): ) data = pytest_report_to_serializable(report) assert data is not None + # Ensure the report is actually serializable to JSON. + _ = json.dumps(data) new_report = pytest_report_from_serializable(data) assert new_report is not None assert new_report.context == SubtestContext( - msg="custom message", kwargs=dict(i=saferepr(10), a=saferepr(MyEnum.A)) + msg="custom message", kwargs=dict(i=10, a=saferepr(MyEnum.A)) ) -def test_serialization_xdist(pytester: pytest.Pytester) -> None: +def test_serialization_xdist(pytester: pytest.Pytester) -> None: # pragma: no cover """Regression test for pytest-dev/pytest-xdist#1273.""" pytest.importorskip("xdist") pytester.makepyfile( From 5228d92cdf074c5c69a6804f17fbfc255add3c9d Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 27 Nov 2025 16:05:44 -0300 Subject: [PATCH 3/4] Use pickle --- src/_pytest/subtests.py | 21 ++++++--------------- testing/test_subtests.py | 13 ++++++++----- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/src/_pytest/subtests.py b/src/_pytest/subtests.py index fbd01e71d1d..2412e3eecb4 100644 --- a/src/_pytest/subtests.py +++ b/src/_pytest/subtests.py @@ -11,7 +11,7 @@ from contextlib import ExitStack from contextlib import nullcontext import dataclasses -import json +import pickle import time from types import TracebackType from typing import Any @@ -64,24 +64,15 @@ class SubtestContext: def _to_json(self) -> dict[str, Any]: result = dataclasses.asdict(self) - - # Best-effort to convert the kwargs values to JSON (pytest-dev/pytest-xdist#1273). - # If they can be converted, we return as it is, otherwise we return its saferepr because it seems - # this is the best we can do at this point. - def convert(x: Any) -> Any: - try: - json.dumps(x) - except TypeError: - return saferepr(x) - else: - return x - - result["kwargs"] = {k: convert(v) for (k, v) in result["kwargs"].items()} + # Use protocol 0 because it is human-readable and guaranteed to be not-binary. + protocol = 0 + data = pickle.dumps(result["kwargs"], protocol=protocol) + result["kwargs"] = data.decode("UTF-8") return result @classmethod def _from_json(cls, d: dict[str, Any]) -> Self: - return cls(msg=d["msg"], kwargs=d["kwargs"]) + return cls(msg=d["msg"], kwargs=pickle.loads(d["kwargs"].encode("UTF-8"))) @dataclasses.dataclass(init=False) diff --git a/testing/test_subtests.py b/testing/test_subtests.py index fbd9cc7e6ef..67d567afd1c 100644 --- a/testing/test_subtests.py +++ b/testing/test_subtests.py @@ -5,7 +5,6 @@ import sys from typing import Literal -from _pytest._io.saferepr import saferepr from _pytest.subtests import SubtestContext from _pytest.subtests import SubtestReport import pytest @@ -960,14 +959,17 @@ def test(subtests): ) +class MyEnum(Enum): + """Used in test_serialization, needs to be declared at the module level to be pickled.""" + + A = "A" + + def test_serialization() -> None: """Ensure subtest's kwargs are serialized using `saferepr` (pytest-dev/pytest-xdist#1273).""" from _pytest.subtests import pytest_report_from_serializable from _pytest.subtests import pytest_report_to_serializable - class MyEnum(Enum): - A = "A" - report = SubtestReport( "test_foo::test_foo", ("test_foo.py", 12, ""), @@ -984,7 +986,7 @@ class MyEnum(Enum): new_report = pytest_report_from_serializable(data) assert new_report is not None assert new_report.context == SubtestContext( - msg="custom message", kwargs=dict(i=10, a=saferepr(MyEnum.A)) + msg="custom message", kwargs=dict(i=10, a=MyEnum.A) ) @@ -1010,5 +1012,6 @@ def test(self): pass """ ) + pytester.syspathinsert() result = pytester.runpytest("-n1", "-pxdist.plugin") result.assert_outcomes(passed=2) From de923a008ef80ac03747bb370402d07d71f879e3 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 16 Dec 2025 19:22:43 -0300 Subject: [PATCH 4/4] Use saferepr right after constructing SubtestContext --- src/_pytest/subtests.py | 13 +++++++------ testing/test_subtests.py | 27 ++++++++++++++------------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/_pytest/subtests.py b/src/_pytest/subtests.py index 2412e3eecb4..4856f72b9ff 100644 --- a/src/_pytest/subtests.py +++ b/src/_pytest/subtests.py @@ -11,7 +11,6 @@ from contextlib import ExitStack from contextlib import nullcontext import dataclasses -import pickle import time from types import TracebackType from typing import Any @@ -62,17 +61,19 @@ class SubtestContext: msg: str | None kwargs: Mapping[str, Any] + def __post_init__(self) -> None: + # Brute-force the returned kwargs dict to be JSON serializable (pytest-dev/pytest-xdist#1273). + object.__setattr__( + self, "kwargs", {k: saferepr(v) for (k, v) in self.kwargs.items()} + ) + def _to_json(self) -> dict[str, Any]: result = dataclasses.asdict(self) - # Use protocol 0 because it is human-readable and guaranteed to be not-binary. - protocol = 0 - data = pickle.dumps(result["kwargs"], protocol=protocol) - result["kwargs"] = data.decode("UTF-8") return result @classmethod def _from_json(cls, d: dict[str, Any]) -> Self: - return cls(msg=d["msg"], kwargs=pickle.loads(d["kwargs"].encode("UTF-8"))) + return cls(msg=d["msg"], kwargs=d["kwargs"]) @dataclasses.dataclass(init=False) diff --git a/testing/test_subtests.py b/testing/test_subtests.py index 67d567afd1c..c480bb01658 100644 --- a/testing/test_subtests.py +++ b/testing/test_subtests.py @@ -5,6 +5,7 @@ import sys from typing import Literal +from _pytest._io.saferepr import saferepr from _pytest.subtests import SubtestContext from _pytest.subtests import SubtestReport import pytest @@ -304,10 +305,10 @@ def test_foo(subtests, x): result = pytester.runpytest("-v") result.stdout.fnmatch_lines( [ - "*.py::test_foo[[]0[]] SUBFAILED[[]custom[]] (i=1) *[[] 50%[]]", - "*.py::test_foo[[]0[]] FAILED *[[] 50%[]]", - "*.py::test_foo[[]1[]] SUBFAILED[[]custom[]] (i=1) *[[]100%[]]", - "*.py::test_foo[[]1[]] FAILED *[[]100%[]]", + "*.py::test_foo[[]0[]] SUBFAILED[[]custom[]] (i='1') *[[] 50%[]]", + "*.py::test_foo[[]0[]] FAILED *[[] 50%[]]", + "*.py::test_foo[[]1[]] SUBFAILED[[]custom[]] (i='1') *[[]100%[]]", + "*.py::test_foo[[]1[]] FAILED *[[]100%[]]", "contains 1 failed subtest", "* 4 failed, 4 subtests passed in *", ] @@ -322,10 +323,10 @@ def test_foo(subtests, x): result = pytester.runpytest("-v") result.stdout.fnmatch_lines( [ - "*.py::test_foo[[]0[]] SUBFAILED[[]custom[]] (i=1) *[[] 50%[]]", - "*.py::test_foo[[]0[]] FAILED *[[] 50%[]]", - "*.py::test_foo[[]1[]] SUBFAILED[[]custom[]] (i=1) *[[]100%[]]", - "*.py::test_foo[[]1[]] FAILED *[[]100%[]]", + "*.py::test_foo[[]0[]] SUBFAILED[[]custom[]] (i='1') *[[] 50%[]]", + "*.py::test_foo[[]0[]] FAILED *[[] 50%[]]", + "*.py::test_foo[[]1[]] SUBFAILED[[]custom[]] (i='1') *[[]100%[]]", + "*.py::test_foo[[]1[]] FAILED *[[]100%[]]", "contains 1 failed subtest", "* 4 failed in *", ] @@ -652,12 +653,12 @@ def test_capturing(self, pytester: pytest.Pytester, mode: str) -> None: result = pytester.runpytest(f"--capture={mode}") result.stdout.fnmatch_lines( [ - "*__ test (i='A') __*", + "*__ test (i=\"'A'\") __*", "*Captured stdout call*", "hello stdout A", "*Captured stderr call*", "hello stderr A", - "*__ test (i='B') __*", + "*__ test (i=\"'B'\") __*", "*Captured stdout call*", "hello stdout B", "*Captured stderr call*", @@ -678,8 +679,8 @@ def test_no_capture(self, pytester: pytest.Pytester) -> None: "hello stdout A", "uhello stdout B", "uend test", - "*__ test (i='A') __*", - "*__ test (i='B') __*", + "*__ test (i=\"'A'\") __*", + "*__ test (i=\"'B'\") __*", "*__ test __*", ] ) @@ -986,7 +987,7 @@ def test_serialization() -> None: new_report = pytest_report_from_serializable(data) assert new_report is not None assert new_report.context == SubtestContext( - msg="custom message", kwargs=dict(i=10, a=MyEnum.A) + msg="custom message", kwargs=dict(i=saferepr(10), a=saferepr(MyEnum.A)) )