From 8ce35c36dbf754a9070291d7ce542472fc152d38 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Sun, 28 Jun 2026 15:12:00 +0300 Subject: [PATCH 01/13] gh-152405: Fix crash of rich-comparing `MappingProxyType` objects --- Lib/test/test_types.py | 68 ++++++++++++++++++- ...-06-28-14-39-05.gh-issue-152405.-_3YRo.rst | 6 ++ Objects/descrobject.c | 17 ++++- 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-06-28-14-39-05.gh-issue-152405.-_3YRo.rst diff --git a/Lib/test/test_types.py b/Lib/test/test_types.py index 2084b30d71ff6ca..333bade7e36b7fb 100644 --- a/Lib/test/test_types.py +++ b/Lib/test/test_types.py @@ -1392,7 +1392,8 @@ def __hash__(self): self.assertEqual(hash(view), hash(mapping)) def test_richcompare(self): - mp1 = self.mappingproxy({'a': 1}) + dt1 = {'a': 1} + mp1 = self.mappingproxy(dt1) mp1_2 = self.mappingproxy({'a': 1}) mp2 = self.mappingproxy({'a': 2}) @@ -1400,6 +1401,10 @@ def test_richcompare(self): self.assertFalse(mp1 != mp1_2) self.assertFalse(mp1 == mp2) self.assertTrue(mp1 != mp2) + self.assertTrue(mp1 == dt1) + self.assertTrue(mp1_2 == dt1) + self.assertFalse(mp2 == dt1) + self.assertTrue(mp2 != dt1) msg = "not supported between instances of 'mappingproxy' and 'mappingproxy'" @@ -1411,6 +1416,67 @@ def test_richcompare(self): mp2 >= mp2 with self.assertRaisesRegex(TypeError, msg): mp1_2 <= mp1 + with self.assertRaisesRegex( + TypeError, + "not supported between instances of 'mappingproxy' and 'dict'", + ): + mp1_2 <= dt1 + + def test_richcompare_mapping(self): + class CustomMapping(collections.abc.Mapping): + def __init__(self, data): + self._data = data + + def __getitem__(self, key): + return self._data[key] + + def __iter__(self): + return iter(self._data) + + def __len__(self): + return len(self._data) + + def __contains__(self, item): + return item in self._data + + dt1 = {'a': 1} + mp1 = self.mappingproxy(CustomMapping(dt1)) + mp1_2 = self.mappingproxy(dt1) + mp2 = self.mappingproxy({'a': 2}) + + self.assertTrue(mp1 == mp1_2) + self.assertTrue(mp1 == dt1) + self.assertTrue(mp1_2 == dt1) + self.assertTrue(dt1 == mp1) + self.assertTrue(dt1 == mp1_2) + self.assertFalse(mp2 == dt1) + self.assertFalse(dt1 == mp2) + self.assertFalse(mp1 != dt1) + self.assertFalse(dt1 != mp1) + + class Evil: + def __eq__(self, other): + return other + + leaked = mp1 == Evil() + self.assertIs(type(leaked), dict) + + def test_richcompare_evil(self): + # https://github.com/python/cpython/issues/152405 + class Evil: + def __eq__(self, other): + return other + + # exposes the internals of `MappingProxyType` via richcompare: + leaked = vars(list) == Evil() + self.assertIs(type(leaked), dict) + name = "__mappingproxy_crash_probe__" + leaked[name] = lambda self: "probe" + self.assertIn(name, leaked) + self.assertNotHasAttr(list, name) # it used to return `True` + del leaked[name] + self.assertNotIn(name, leaked) + self.assertNotHasAttr(list, name) # it used to crash class ClassCreationTests(unittest.TestCase): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-28-14-39-05.gh-issue-152405.-_3YRo.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-28-14-39-05.gh-issue-152405.-_3YRo.rst new file mode 100644 index 000000000000000..386be7260c2de44 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-28-14-39-05.gh-issue-152405.-_3YRo.rst @@ -0,0 +1,6 @@ +Fixes a crash, when modifing :class:`types.MappingProxyType` exposed via a +rich-compare operation with :func:`vars` function. It was possible to mutate +the internals of builtins types via this technique. + +It changes the object that is passed to rich-compare methods +from the real internal mapping type to be always :class:`dict`. diff --git a/Objects/descrobject.c b/Objects/descrobject.c index 30444b7d6774247..4026f903cd85e75 100644 --- a/Objects/descrobject.c +++ b/Objects/descrobject.c @@ -1232,9 +1232,22 @@ mappingproxy_traverse(PyObject *self, visitproc visit, void *arg) static PyObject * mappingproxy_richcompare(PyObject *self, PyObject *w, int op) { - mappingproxyobject *v = (mappingproxyobject *)self; if (op == Py_EQ || op == Py_NE) { - return PyObject_RichCompare(v->mapping, w, op); + mappingproxyobject *v = (mappingproxyobject *)self; + // We can't expose the `v->mapping` itself, so we create a dict copy: + // it was possible to mutate `v->mapping` in rich-compare methods. + // See gh-152405 on the details. + PyObject *copy = PyDict_New(); + if (copy == NULL) { + return NULL; + } + if (PyDict_Update(copy, v->mapping)) { + Py_DECREF(copy); + return NULL; + } + PyObject *res = PyObject_RichCompare(copy, w, op); + Py_DECREF(copy); + return res; } Py_RETURN_NOTIMPLEMENTED; } From fb78a8a9218e7b9bedfe209780e402c7544c3810 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Sun, 28 Jun 2026 15:21:25 +0300 Subject: [PATCH 02/13] Optimize the common path --- Objects/descrobject.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Objects/descrobject.c b/Objects/descrobject.c index 4026f903cd85e75..d4c4fbd8b120c6a 100644 --- a/Objects/descrobject.c +++ b/Objects/descrobject.c @@ -1234,6 +1234,10 @@ mappingproxy_richcompare(PyObject *self, PyObject *w, int op) { if (op == Py_EQ || op == Py_NE) { mappingproxyobject *v = (mappingproxyobject *)self; + // Common path optimization: + if (PyDict_CheckExact(w)) { + return PyObject_RichCompare(v->mapping, w, op); + } // We can't expose the `v->mapping` itself, so we create a dict copy: // it was possible to mutate `v->mapping` in rich-compare methods. // See gh-152405 on the details. From aa846174e4a3834e50cab158c597aacaa9d64cd2 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Sun, 28 Jun 2026 16:08:44 +0300 Subject: [PATCH 03/13] Fix CI --- Lib/test/test_types.py | 27 ++++++++++++++++++++++++++- Objects/descrobject.c | 9 +++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_types.py b/Lib/test/test_types.py index 333bade7e36b7fb..9c2163073076c57 100644 --- a/Lib/test/test_types.py +++ b/Lib/test/test_types.py @@ -8,7 +8,7 @@ from test.support.import_helper import import_fresh_module import collections.abc -from collections import namedtuple, UserDict +from collections import namedtuple, UserDict, OrderedDict import copy import _datetime import gc @@ -1461,18 +1461,43 @@ def __eq__(self, other): leaked = mp1 == Evil() self.assertIs(type(leaked), dict) + def test_richcompare_odict(self): + od1 = OrderedDict(x=1, y=2) + od1_2 = OrderedDict(x=1, y=2) + od2 = OrderedDict(y=2, x=1) + self.assertNotEqual(od1, od2) + self.assertEqual(od1, od1_2) + + self.assertEqual(self.mappingproxy(od1), self.mappingproxy(od1_2)) + self.assertEqual(self.mappingproxy(od1), od1_2) + self.assertNotEqual(self.mappingproxy(od1), self.mappingproxy(od2)) + self.assertNotEqual(self.mappingproxy(od1), od2) + self.assertEqual( + self.mappingproxy(od1), + self.mappingproxy({'x': 1, 'y': 2}), + ) + self.assertEqual( + self.mappingproxy(od1), + {'x': 1, 'y': 2}, + ) + def test_richcompare_evil(self): # https://github.com/python/cpython/issues/152405 + key = "__mappingproxy_crash_key__" + class Evil: def __eq__(self, other): + other[key] = 1 return other # exposes the internals of `MappingProxyType` via richcompare: leaked = vars(list) == Evil() self.assertIs(type(leaked), dict) + self.assertIn(key, leaked) name = "__mappingproxy_crash_probe__" leaked[name] = lambda self: "probe" self.assertIn(name, leaked) + self.assertNotHasAttr(list, key) self.assertNotHasAttr(list, name) # it used to return `True` del leaked[name] self.assertNotIn(name, leaked) diff --git a/Objects/descrobject.c b/Objects/descrobject.c index d4c4fbd8b120c6a..910b240287c7b41 100644 --- a/Objects/descrobject.c +++ b/Objects/descrobject.c @@ -1234,8 +1234,13 @@ mappingproxy_richcompare(PyObject *self, PyObject *w, int op) { if (op == Py_EQ || op == Py_NE) { mappingproxyobject *v = (mappingproxyobject *)self; - // Common path optimization: - if (PyDict_CheckExact(w)) { + // Common path optimizations, where we can expose the real mapping: + if ( + PyAnyDict_CheckExact(w) + || PyODict_CheckExact(w) + || Py_TYPE(w) == &PyDictProxy_Type + ) { + fprintf(stderr, "optimized %s richcompare\n", Py_TYPE(w)->tp_name); return PyObject_RichCompare(v->mapping, w, op); } // We can't expose the `v->mapping` itself, so we create a dict copy: From 16ec4fb21f68184ef450f5fd8dc4ec39304b2a48 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Sun, 28 Jun 2026 16:12:40 +0300 Subject: [PATCH 04/13] Better wording for the NEWS --- .../2026-06-28-14-39-05.gh-issue-152405.-_3YRo.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-28-14-39-05.gh-issue-152405.-_3YRo.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-28-14-39-05.gh-issue-152405.-_3YRo.rst index 386be7260c2de44..35ab1bf67ce12b3 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-28-14-39-05.gh-issue-152405.-_3YRo.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-28-14-39-05.gh-issue-152405.-_3YRo.rst @@ -2,5 +2,6 @@ Fixes a crash, when modifing :class:`types.MappingProxyType` exposed via a rich-compare operation with :func:`vars` function. It was possible to mutate the internals of builtins types via this technique. -It changes the object that is passed to rich-compare methods -from the real internal mapping type to be always :class:`dict`. +It changes the object that is passed to custom rich-compare methods +from the original internal mapping type to be always a new :class:`dict` copy +with all the keys and values from the original mapping. From 151359b7ccca904776799734a44f8221364cdb4e Mon Sep 17 00:00:00 2001 From: sobolevn Date: Sun, 28 Jun 2026 16:34:13 +0300 Subject: [PATCH 05/13] Remove debug print --- Objects/descrobject.c | 1 - 1 file changed, 1 deletion(-) diff --git a/Objects/descrobject.c b/Objects/descrobject.c index 910b240287c7b41..e1f0a6ea9c48278 100644 --- a/Objects/descrobject.c +++ b/Objects/descrobject.c @@ -1240,7 +1240,6 @@ mappingproxy_richcompare(PyObject *self, PyObject *w, int op) || PyODict_CheckExact(w) || Py_TYPE(w) == &PyDictProxy_Type ) { - fprintf(stderr, "optimized %s richcompare\n", Py_TYPE(w)->tp_name); return PyObject_RichCompare(v->mapping, w, op); } // We can't expose the `v->mapping` itself, so we create a dict copy: From 12ed6a9b07af06660e2b7eff41e236717ed2582b Mon Sep 17 00:00:00 2001 From: sobolevn Date: Sun, 28 Jun 2026 16:43:06 +0300 Subject: [PATCH 06/13] More tests --- Lib/test/test_types.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Lib/test/test_types.py b/Lib/test/test_types.py index 9c2163073076c57..a5127102397fafc 100644 --- a/Lib/test/test_types.py +++ b/Lib/test/test_types.py @@ -1402,9 +1402,11 @@ def test_richcompare(self): self.assertFalse(mp1 == mp2) self.assertTrue(mp1 != mp2) self.assertTrue(mp1 == dt1) + self.assertTrue(dt1 == mp1) self.assertTrue(mp1_2 == dt1) self.assertFalse(mp2 == dt1) self.assertTrue(mp2 != dt1) + self.assertTrue(dt1 != mp2) msg = "not supported between instances of 'mappingproxy' and 'mappingproxy'" @@ -1444,11 +1446,13 @@ def __contains__(self, item): mp1_2 = self.mappingproxy(dt1) mp2 = self.mappingproxy({'a': 2}) + self.assertTrue(mp1 == mp1) self.assertTrue(mp1 == mp1_2) self.assertTrue(mp1 == dt1) self.assertTrue(mp1_2 == dt1) self.assertTrue(dt1 == mp1) self.assertTrue(dt1 == mp1_2) + self.assertFalse(mp1 != mp1) self.assertFalse(mp2 == dt1) self.assertFalse(dt1 == mp2) self.assertFalse(mp1 != dt1) @@ -1460,6 +1464,7 @@ def __eq__(self, other): leaked = mp1 == Evil() self.assertIs(type(leaked), dict) + self.assertEqual(leaked, dt1) def test_richcompare_odict(self): od1 = OrderedDict(x=1, y=2) From ec8dec292348eac444f3283044fc58d48e72200b Mon Sep 17 00:00:00 2001 From: sobolevn Date: Sun, 28 Jun 2026 18:41:22 +0300 Subject: [PATCH 07/13] Use a different approach, only copy dicts themself --- Lib/test/test_types.py | 13 +++++++- ...-06-28-14-39-05.gh-issue-152405.-_3YRo.rst | 4 +-- Objects/descrobject.c | 31 +++++++------------ 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/Lib/test/test_types.py b/Lib/test/test_types.py index a5127102397fafc..41396cb7b0388bb 100644 --- a/Lib/test/test_types.py +++ b/Lib/test/test_types.py @@ -1463,9 +1463,20 @@ def __eq__(self, other): return other leaked = mp1 == Evil() - self.assertIs(type(leaked), dict) + self.assertIs(type(leaked), CustomMapping) + self.assertEqual(leaked, mp1) self.assertEqual(leaked, dt1) + class CustomMapping2(CustomMapping): + def __eq__(self, other): + return ( + isinstance(other, CustomMapping) + and self._data == other._data + ) + + self.assertEqual(mp1, CustomMapping2({'a': 1})) + self.assertNotEqual(CustomMapping2({'a': 1}), mp1) + def test_richcompare_odict(self): od1 = OrderedDict(x=1, y=2) od1_2 = OrderedDict(x=1, y=2) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-28-14-39-05.gh-issue-152405.-_3YRo.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-28-14-39-05.gh-issue-152405.-_3YRo.rst index 35ab1bf67ce12b3..0dd20e691f038e3 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-28-14-39-05.gh-issue-152405.-_3YRo.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-28-14-39-05.gh-issue-152405.-_3YRo.rst @@ -2,6 +2,4 @@ Fixes a crash, when modifing :class:`types.MappingProxyType` exposed via a rich-compare operation with :func:`vars` function. It was possible to mutate the internals of builtins types via this technique. -It changes the object that is passed to custom rich-compare methods -from the original internal mapping type to be always a new :class:`dict` copy -with all the keys and values from the original mapping. +Now we pass not the original :class:`dict` instance, but its copy. diff --git a/Objects/descrobject.c b/Objects/descrobject.c index e1f0a6ea9c48278..7206de893d6cbff 100644 --- a/Objects/descrobject.c +++ b/Objects/descrobject.c @@ -1234,28 +1234,21 @@ mappingproxy_richcompare(PyObject *self, PyObject *w, int op) { if (op == Py_EQ || op == Py_NE) { mappingproxyobject *v = (mappingproxyobject *)self; - // Common path optimizations, where we can expose the real mapping: - if ( - PyAnyDict_CheckExact(w) - || PyODict_CheckExact(w) - || Py_TYPE(w) == &PyDictProxy_Type - ) { - return PyObject_RichCompare(v->mapping, w, op); - } - // We can't expose the `v->mapping` itself, so we create a dict copy: - // it was possible to mutate `v->mapping` in rich-compare methods. + // We have to guard the mutable `dict` instances, because it can + // otherwise mutate the type's `__dict__` entries and cause crashes: // See gh-152405 on the details. - PyObject *copy = PyDict_New(); - if (copy == NULL) { - return NULL; - } - if (PyDict_Update(copy, v->mapping)) { + if (PyDict_CheckExact(v->mapping)) { + // So, instead we send a copy: + PyObject *copy = PyDict_Copy(v->mapping); + if (copy == NULL) { + return NULL; + } + PyObject *res = PyObject_RichCompare(copy, w, op); Py_DECREF(copy); - return NULL; + return res; } - PyObject *res = PyObject_RichCompare(copy, w, op); - Py_DECREF(copy); - return res; + // Otherwise we are free to share the mapping directly: + return PyObject_RichCompare(v->mapping, w, op); } Py_RETURN_NOTIMPLEMENTED; } From c581cc3b39e0a98372991cd1be1e7b8c2f41182a Mon Sep 17 00:00:00 2001 From: sobolevn Date: Sun, 28 Jun 2026 19:55:38 +0300 Subject: [PATCH 08/13] Optimize memory usage for common cases --- Lib/test/test_types.py | 9 ++++++++- Objects/descrobject.c | 8 ++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_types.py b/Lib/test/test_types.py index 41396cb7b0388bb..d99e98d5012fb3a 100644 --- a/Lib/test/test_types.py +++ b/Lib/test/test_types.py @@ -1506,7 +1506,14 @@ def __eq__(self, other): other[key] = 1 return other - # exposes the internals of `MappingProxyType` via richcompare: + # Checks that it does not mutate the internals of `MappingProxyType`: + dc = {} + leaked = self.mappingproxy(dc) == Evil() + self.assertIs(type(leaked), dict) + self.assertIn(key, leaked) + self.assertNotIn(key, dc) + + # Exposes the internals of `MappingProxyType` via richcompare: leaked = vars(list) == Evil() self.assertIs(type(leaked), dict) self.assertIn(key, leaked) diff --git a/Objects/descrobject.c b/Objects/descrobject.c index 7206de893d6cbff..5dd8d25448cc819 100644 --- a/Objects/descrobject.c +++ b/Objects/descrobject.c @@ -1235,9 +1235,13 @@ mappingproxy_richcompare(PyObject *self, PyObject *w, int op) if (op == Py_EQ || op == Py_NE) { mappingproxyobject *v = (mappingproxyobject *)self; // We have to guard the mutable `dict` instances, because it can - // otherwise mutate the type's `__dict__` entries and cause crashes: + // otherwise mutate the type's `__dict__` entries and cause crashes. + // But, do not create copies on known types for memory optimization. // See gh-152405 on the details. - if (PyDict_CheckExact(v->mapping)) { + if ( + PyDict_CheckExact(v->mapping) && + !(PyAnyDict_CheckExact(w) || PyODict_CheckExact(w)) + ) { // So, instead we send a copy: PyObject *copy = PyDict_Copy(v->mapping); if (copy == NULL) { From c87a2d0be42d34cff1c7badd037f0d1842cdab35 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Mon, 29 Jun 2026 18:29:05 +0300 Subject: [PATCH 09/13] Address review --- Lib/test/test_types.py | 108 +++++++++++++++++------------------------ Objects/descrobject.c | 5 +- 2 files changed, 48 insertions(+), 65 deletions(-) diff --git a/Lib/test/test_types.py b/Lib/test/test_types.py index d99e98d5012fb3a..290e34391d2652a 100644 --- a/Lib/test/test_types.py +++ b/Lib/test/test_types.py @@ -1391,40 +1391,62 @@ def __hash__(self): view = self.mappingproxy(mapping) self.assertEqual(hash(view), hash(mapping)) - def test_richcompare(self): - dt1 = {'a': 1} - mp1 = self.mappingproxy(dt1) - mp1_2 = self.mappingproxy({'a': 1}) - mp2 = self.mappingproxy({'a': 2}) - - self.assertTrue(mp1 == mp1_2) - self.assertFalse(mp1 != mp1_2) + def check_richcompare(self, mapping_type): + data1 = mapping_type({'a': 1}) + data2 = mapping_type({'a': 2}) + mp1 = self.mappingproxy(data1) + copy1 = self.mappingproxy(data1) + mp2 = self.mappingproxy(data2) + + self.assertTrue(mp1 == data1) + self.assertTrue(data1 == mp1) + self.assertTrue(copy1 == data1) + + self.assertTrue(mp1 == copy1) + self.assertFalse(mp1 != copy1) + self.assertFalse(mp1 == mp2) self.assertTrue(mp1 != mp2) - self.assertTrue(mp1 == dt1) - self.assertTrue(dt1 == mp1) - self.assertTrue(mp1_2 == dt1) - self.assertFalse(mp2 == dt1) - self.assertTrue(mp2 != dt1) - self.assertTrue(dt1 != mp2) + self.assertFalse(mp2 == data1) + self.assertTrue(mp2 != data1) + self.assertTrue(data1 != mp2) msg = "not supported between instances of 'mappingproxy' and 'mappingproxy'" - with self.assertRaisesRegex(TypeError, msg): mp1 > mp2 with self.assertRaisesRegex(TypeError, msg): - mp1 < mp1_2 + mp1 < copy1 with self.assertRaisesRegex(TypeError, msg): mp2 >= mp2 with self.assertRaisesRegex(TypeError, msg): - mp1_2 <= mp1 + copy1 <= mp1 + + if mapping_type.__module__ == 'collections': + mapping_name = f'{mapping_type.__module__}.{mapping_type.__name__}' + else: + mapping_name = mapping_type.__name__ with self.assertRaisesRegex( TypeError, - "not supported between instances of 'mappingproxy' and 'dict'", + f"not supported between instances of 'mappingproxy' and '{mapping_name}'", ): - mp1_2 <= dt1 + copy1 <= data1 + + class Evil: + def __eq__(self, other): + return other + + result = (mp1 == Evil()) + self.assertIs(type(result), mapping_type) + if mapping_type == dict: + # Evil.__eq__() gets a copy of the mapping + self.assertEqual(result, data1) + else: + self.assertIs(result, data1) + + def test_richcompare_dict(self): + self.check_richcompare(dict) - def test_richcompare_mapping(self): + def test_richcompare_custom_mapping(self): class CustomMapping(collections.abc.Mapping): def __init__(self, data): self._data = data @@ -1441,31 +1463,7 @@ def __len__(self): def __contains__(self, item): return item in self._data - dt1 = {'a': 1} - mp1 = self.mappingproxy(CustomMapping(dt1)) - mp1_2 = self.mappingproxy(dt1) - mp2 = self.mappingproxy({'a': 2}) - - self.assertTrue(mp1 == mp1) - self.assertTrue(mp1 == mp1_2) - self.assertTrue(mp1 == dt1) - self.assertTrue(mp1_2 == dt1) - self.assertTrue(dt1 == mp1) - self.assertTrue(dt1 == mp1_2) - self.assertFalse(mp1 != mp1) - self.assertFalse(mp2 == dt1) - self.assertFalse(dt1 == mp2) - self.assertFalse(mp1 != dt1) - self.assertFalse(dt1 != mp1) - - class Evil: - def __eq__(self, other): - return other - - leaked = mp1 == Evil() - self.assertIs(type(leaked), CustomMapping) - self.assertEqual(leaked, mp1) - self.assertEqual(leaked, dt1) + self.check_richcompare(CustomMapping) class CustomMapping2(CustomMapping): def __eq__(self, other): @@ -1474,28 +1472,12 @@ def __eq__(self, other): and self._data == other._data ) + mp1 = self.mappingproxy(CustomMapping({'a': 1})) self.assertEqual(mp1, CustomMapping2({'a': 1})) self.assertNotEqual(CustomMapping2({'a': 1}), mp1) def test_richcompare_odict(self): - od1 = OrderedDict(x=1, y=2) - od1_2 = OrderedDict(x=1, y=2) - od2 = OrderedDict(y=2, x=1) - self.assertNotEqual(od1, od2) - self.assertEqual(od1, od1_2) - - self.assertEqual(self.mappingproxy(od1), self.mappingproxy(od1_2)) - self.assertEqual(self.mappingproxy(od1), od1_2) - self.assertNotEqual(self.mappingproxy(od1), self.mappingproxy(od2)) - self.assertNotEqual(self.mappingproxy(od1), od2) - self.assertEqual( - self.mappingproxy(od1), - self.mappingproxy({'x': 1, 'y': 2}), - ) - self.assertEqual( - self.mappingproxy(od1), - {'x': 1, 'y': 2}, - ) + self.check_richcompare(OrderedDict) def test_richcompare_evil(self): # https://github.com/python/cpython/issues/152405 diff --git a/Objects/descrobject.c b/Objects/descrobject.c index 5dd8d25448cc819..2cb8ac9407b3512 100644 --- a/Objects/descrobject.c +++ b/Objects/descrobject.c @@ -1236,8 +1236,9 @@ mappingproxy_richcompare(PyObject *self, PyObject *w, int op) mappingproxyobject *v = (mappingproxyobject *)self; // We have to guard the mutable `dict` instances, because it can // otherwise mutate the type's `__dict__` entries and cause crashes. - // But, do not create copies on known types for memory optimization. - // See gh-152405 on the details. + // But, do not create copies on known types like `OrderedDict` + // or immutable types like `frozendict` + // for memory optimization. See gh-152405 for the details. if ( PyDict_CheckExact(v->mapping) && !(PyAnyDict_CheckExact(w) || PyODict_CheckExact(w)) From 9d4f18e4a94ca2033abec72b563bdca2fae71775 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Mon, 29 Jun 2026 18:34:38 +0300 Subject: [PATCH 10/13] Test frozendict --- Lib/test/test_types.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Lib/test/test_types.py b/Lib/test/test_types.py index 290e34391d2652a..b98ec5606aeaab9 100644 --- a/Lib/test/test_types.py +++ b/Lib/test/test_types.py @@ -1479,6 +1479,9 @@ def __eq__(self, other): def test_richcompare_odict(self): self.check_richcompare(OrderedDict) + def test_richcompare_frozendict(self): + self.check_richcompare(frozendict) + def test_richcompare_evil(self): # https://github.com/python/cpython/issues/152405 key = "__mappingproxy_crash_key__" From 6933daf76a13150abff3d4852420f6571361fa6d Mon Sep 17 00:00:00 2001 From: sobolevn Date: Mon, 29 Jun 2026 19:31:40 +0300 Subject: [PATCH 11/13] Add a comment --- Lib/test/test_types.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lib/test/test_types.py b/Lib/test/test_types.py index b98ec5606aeaab9..53e0289b10feef0 100644 --- a/Lib/test/test_types.py +++ b/Lib/test/test_types.py @@ -1483,6 +1483,8 @@ def test_richcompare_frozendict(self): self.check_richcompare(frozendict) def test_richcompare_evil(self): + # This test used to mutate the list dictionary, + # but MappingProxyType now creates a copy to call `__eq__`: # https://github.com/python/cpython/issues/152405 key = "__mappingproxy_crash_key__" From cb8d471d3d44eb713a3991a15d0be5bcfe304315 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Mon, 29 Jun 2026 20:04:41 +0300 Subject: [PATCH 12/13] Also do no copy on `PyDictProxy_Type` compares --- Objects/descrobject.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Objects/descrobject.c b/Objects/descrobject.c index 2cb8ac9407b3512..cfcafb56d46cc39 100644 --- a/Objects/descrobject.c +++ b/Objects/descrobject.c @@ -1241,7 +1241,9 @@ mappingproxy_richcompare(PyObject *self, PyObject *w, int op) // for memory optimization. See gh-152405 for the details. if ( PyDict_CheckExact(v->mapping) && - !(PyAnyDict_CheckExact(w) || PyODict_CheckExact(w)) + !(PyAnyDict_CheckExact(w) || + Py_TYPE(w) == &PyDictProxy_Type || + PyODict_CheckExact(w)) ) { // So, instead we send a copy: PyObject *copy = PyDict_Copy(v->mapping); From 7db421601a4527f72e0d9e8b225458db708f16c8 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Tue, 7 Jul 2026 11:34:48 +0300 Subject: [PATCH 13/13] Change wording --- .../2026-06-28-14-39-05.gh-issue-152405.-_3YRo.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-28-14-39-05.gh-issue-152405.-_3YRo.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-28-14-39-05.gh-issue-152405.-_3YRo.rst index 0dd20e691f038e3..d7c473ce171d4b7 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-28-14-39-05.gh-issue-152405.-_3YRo.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-28-14-39-05.gh-issue-152405.-_3YRo.rst @@ -1,5 +1,5 @@ -Fixes a crash, when modifing :class:`types.MappingProxyType` exposed via a -rich-compare operation with :func:`vars` function. It was possible to mutate -the internals of builtins types via this technique. - -Now we pass not the original :class:`dict` instance, but its copy. +Do not expose the internal mapping of :class:`types.MappingProxyType` +when performing rich-compare operations with non-stdlib types. +Previously, it was possible to mutate the internal mapping of a proxy there. +Now we pass not the original :class:`dict` instance, but its copy, +when dealing with custom types.