From 9e64c66de510065e0f9afd292814eedd5297daca Mon Sep 17 00:00:00 2001 From: Taeknology <20297177+Taeknology@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:20:14 +0900 Subject: [PATCH] gh-59091: Align pure-Python JSON container checks with C Use actual type ancestry for list, tuple, dict, and frozendict dispatch in the pure-Python encoder. This prevents objects that only spoof __class__ from bypassing JSONEncoder.default(), while preserving real container subclasses. --- Lib/json/encoder.py | 12 +-- Lib/test/test_json/test_default.py | 83 +++++++++++++++++++ ...6-08-02-20-03-57.gh-issue-59091.e88bmS.rst | 3 + 3 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-02-20-03-57.gh-issue-59091.e88bmS.rst diff --git a/Lib/json/encoder.py b/Lib/json/encoder.py index 718b3254241c565..0deed4e86388cc3 100644 --- a/Lib/json/encoder.py +++ b/Lib/json/encoder.py @@ -306,9 +306,9 @@ def _iterencode_list(lst, _current_indent_level): yield buf + _floatstr(value) else: yield buf - if isinstance(value, (list, tuple)): + if issubclass(type(value), (list, tuple)): chunks = _iterencode_list(value, _current_indent_level) - elif isinstance(value, (dict, frozendict)): + elif issubclass(type(value), (dict, frozendict)): chunks = _iterencode_dict(value, _current_indent_level) else: chunks = _iterencode(value, _current_indent_level) @@ -393,9 +393,9 @@ def _iterencode_dict(dct, _current_indent_level): # see comment for int/float in _make_iterencode yield _floatstr(value) else: - if isinstance(value, (list, tuple)): + if issubclass(type(value), (list, tuple)): chunks = _iterencode_list(value, _current_indent_level) - elif isinstance(value, (dict, frozendict)): + elif issubclass(type(value), (dict, frozendict)): chunks = _iterencode_dict(value, _current_indent_level) else: chunks = _iterencode(value, _current_indent_level) @@ -427,9 +427,9 @@ def _iterencode(o, _current_indent_level): elif isinstance(o, float): # see comment for int/float in _make_iterencode yield _floatstr(o) - elif isinstance(o, (list, tuple)): + elif issubclass(type(o), (list, tuple)): yield from _iterencode_list(o, _current_indent_level) - elif isinstance(o, (dict, frozendict)): + elif issubclass(type(o), (dict, frozendict)): yield from _iterencode_dict(o, _current_indent_level) else: if markers is not None: diff --git a/Lib/test/test_json/test_default.py b/Lib/test/test_json/test_default.py index 811880a15c80208..8ec13c354ee161b 100644 --- a/Lib/test/test_json/test_default.py +++ b/Lib/test/test_json/test_default.py @@ -2,6 +2,21 @@ from test.test_json import PyTest, CTest +class ClassSpoof: + def __init__(self, spoofed_class): + self.spoofed_class = spoofed_class + + @property + def __class__(self): + return self.spoofed_class + + def __iter__(self): + return iter(("spoofed",)) + + def items(self): + return (("spoofed", True),) + + class TestDefault: def test_default(self): self.assertEqual( @@ -36,6 +51,74 @@ def test_ordereddict(self): self.dumps(od, sort_keys=True), '{"a": 1, "b": 2, "c": 3, "d": 4}') + def check_spoof_uses_default( + self, value, fallback, expected, spoofed_obj + ): + calls = [] + + def default(candidate): + calls.append(candidate) + return fallback + + encoded = self.dumps(value, default=default) + self.assertEqual(encoded, self.dumps(expected)) + self.assertEqual(len(calls), 1) + self.assertIs(calls[0], spoofed_obj) + + def test_spoofed_containers_use_default_at_top_level(self): + for spoofed_class in (list, tuple, dict, frozendict): + with self.subTest(spoofed_class=spoofed_class): + obj = ClassSpoof(spoofed_class) + fallback = {"defaulted": spoofed_class.__name__} + self.check_spoof_uses_default( + obj, fallback, fallback, obj) + + def test_spoofed_containers_use_default_as_list_item(self): + for spoofed_class in (list, tuple, dict, frozendict): + with self.subTest(spoofed_class=spoofed_class): + obj = ClassSpoof(spoofed_class) + fallback = {"defaulted": spoofed_class.__name__} + self.check_spoof_uses_default( + [obj], fallback, [fallback], obj) + + def test_spoofed_containers_use_default_as_dict_value(self): + for spoofed_class in (list, tuple, dict, frozendict): + with self.subTest(spoofed_class=spoofed_class): + obj = ClassSpoof(spoofed_class) + fallback = {"defaulted": spoofed_class.__name__} + self.check_spoof_uses_default( + {"value": obj}, + fallback, {"value": fallback}, obj) + + def test_spoofed_containers_without_default_raise_type_error(self): + for spoofed_class in (list, tuple, dict, frozendict): + with self.subTest(spoofed_class=spoofed_class): + with self.assertRaises(TypeError): + self.dumps(ClassSpoof(spoofed_class)) + + def test_genuine_container_subclasses_are_encoded(self): + class ListSubclass(list): + pass + + class TupleSubclass(tuple): + pass + + class DictSubclass(dict): + pass + + class FrozenDictSubclass(frozendict): + pass + + values = ( + (ListSubclass((1, 2)), [1, 2]), + (TupleSubclass((1, 2)), [1, 2]), + (DictSubclass(a=1), {"a": 1}), + (FrozenDictSubclass(a=1), {"a": 1}), + ) + for value, expected in values: + with self.subTest(value_type=type(value)): + self.assertEqual(self.dumps(value), self.dumps(expected)) + class TestPyDefault(TestDefault, PyTest): pass class TestCDefault(TestDefault, CTest): pass diff --git a/Misc/NEWS.d/next/Library/2026-08-02-20-03-57.gh-issue-59091.e88bmS.rst b/Misc/NEWS.d/next/Library/2026-08-02-20-03-57.gh-issue-59091.e88bmS.rst new file mode 100644 index 000000000000000..bd667da72e1557e --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-02-20-03-57.gh-issue-59091.e88bmS.rst @@ -0,0 +1,3 @@ +Make the pure-Python :mod:`json` encoder use an object's actual type hierarchy +rather than a spoofed ``__class__`` when recognizing containers, matching the C +accelerator.