From 7585f4500ab8081454151563743e1983e4f51f8e Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 13 Nov 2025 19:27:26 -0300 Subject: [PATCH 1/3] 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 e0ceb27f4b1..d00346e076d 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 fdfa22f0d6999ac5bc877e8a737880f84a0e939f Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 22 Nov 2025 09:18:07 -0300 Subject: [PATCH 2/3] 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 d00346e076d..eecc88abf01 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 b5b4035fb5cca6037d379a047c1a56c9e017f5b0 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 27 Nov 2025 16:05:44 -0300 Subject: [PATCH 3/3] 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 eecc88abf01..04ac36bdf23 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)