From 8d6ec8dff3b0b9f2d6d1a05af27a1c9726f50846 Mon Sep 17 00:00:00 2001 From: mohsinm-dev Date: Sun, 22 Feb 2026 23:56:03 +0500 Subject: [PATCH] gh-145119: Allow frozendict to be assigned to instance __dict__ --- Lib/test/test_capi/test_dict.py | 16 ++++++-- Lib/test/test_descr.py | 39 +++++++++++++++++++ ...-02-22-23-50-00.gh-issue-145119.hN4sQ8.rst | 2 + Objects/dictobject.c | 21 ++++++++++ Objects/typeobject.c | 21 +++++++--- 5 files changed, 89 insertions(+), 10 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-02-22-23-50-00.gh-issue-145119.hN4sQ8.rst diff --git a/Lib/test/test_capi/test_dict.py b/Lib/test/test_capi/test_dict.py index d9de9bb4c8125d..1fd753582302d7 100644 --- a/Lib/test/test_capi/test_dict.py +++ b/Lib/test/test_capi/test_dict.py @@ -273,7 +273,9 @@ def test_dict_setitem(self): self.assertEqual(dct, {'a': 5, '\U0001f40d': 8}) self.assertRaises(TypeError, setitem, {}, [], 5) # unhashable - for test_type in NOT_DICT_TYPES + OTHER_TYPES: + for test_type in FROZENDICT_TYPES: + self.assertRaises(TypeError, setitem, test_type(), 'a', 5) + for test_type in MAPPING_TYPES + OTHER_TYPES: self.assertRaises(SystemError, setitem, test_type(), 'a', 5) # CRASHES setitem({}, NULL, 5) # CRASHES setitem({}, 'a', NULL) @@ -290,7 +292,9 @@ def test_dict_setitemstring(self): self.assertEqual(dct, {'a': 5, '\U0001f40d': 8}) self.assertRaises(UnicodeDecodeError, setitemstring, {}, INVALID_UTF8, 5) - for test_type in NOT_DICT_TYPES + OTHER_TYPES: + for test_type in FROZENDICT_TYPES: + self.assertRaises(TypeError, setitemstring, test_type(), b'a', 5) + for test_type in MAPPING_TYPES + OTHER_TYPES: self.assertRaises(SystemError, setitemstring, test_type(), b'a', 5) # CRASHES setitemstring({}, NULL, 5) # CRASHES setitemstring({}, b'a', NULL) @@ -308,7 +312,9 @@ def test_dict_delitem(self): self.assertEqual(dct, {'c': 2}) self.assertRaises(TypeError, delitem, {}, []) # unhashable - for test_type in NOT_DICT_TYPES: + for test_type in FROZENDICT_TYPES: + self.assertRaises(TypeError, delitem, test_type({'a': 1}), 'a') + for test_type in MAPPING_TYPES: self.assertRaises(SystemError, delitem, test_type({'a': 1}), 'a') for test_type in OTHER_TYPES: self.assertRaises(SystemError, delitem, test_type(), 'a') @@ -327,7 +333,9 @@ def test_dict_delitemstring(self): self.assertEqual(dct, {'c': 2}) self.assertRaises(UnicodeDecodeError, delitemstring, {}, INVALID_UTF8) - for test_type in NOT_DICT_TYPES: + for test_type in FROZENDICT_TYPES: + self.assertRaises(TypeError, delitemstring, test_type({'a': 1}), b'a') + for test_type in MAPPING_TYPES: self.assertRaises(SystemError, delitemstring, test_type({'a': 1}), b'a') for test_type in OTHER_TYPES: self.assertRaises(SystemError, delitemstring, test_type(), b'a') diff --git a/Lib/test/test_descr.py b/Lib/test/test_descr.py index d6e3719479a214..cebfa9e8943b6d 100644 --- a/Lib/test/test_descr.py +++ b/Lib/test/test_descr.py @@ -3570,6 +3570,45 @@ class Exception2(Base, Exception): self.assertEqual(e.a, 1) self.assertEqual(can_delete_dict(e), can_delete_dict(ValueError())) + def test_set_dict_to_frozendict(self): + # gh-145119: __dict__ accepts frozendict. + class C: + pass + + obj = C() + obj.__dict__ = frozendict(x=1, y=2) + self.assertEqual(obj.x, 1) + self.assertEqual(obj.y, 2) + self.assertIn("x", dir(obj)) + self.assertIn("y", dir(obj)) + self.assertEqual(type(vars(obj)), frozendict) + + with self.assertRaises(TypeError): + obj.z = 3 + with self.assertRaises(TypeError): + del obj.x + + class MyFrozenDict(frozendict): + pass + + obj.__dict__ = MyFrozenDict(a=10) + self.assertEqual(obj.a, 10) + self.assertIn("a", dir(obj)) + + obj.__dict__ = {"w": 50} + obj.q = 99 + self.assertEqual(obj.q, 99) + + # Ensure internal PyDict_SetItem/DelItem paths raise TypeError, + # not SystemError, when __dict__ is a frozendict. + cm = classmethod(lambda: None) + cm.__dict__ = frozendict() + with self.assertRaises(TypeError): + cm.__annotations__ = {"x": int} + cm.__dict__ = frozendict(__annotations__={"x": int}) + with self.assertRaises(TypeError): + del cm.__annotations__ + def test_binary_operator_override(self): # Testing overrides of binary operations... class I(int): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-02-22-23-50-00.gh-issue-145119.hN4sQ8.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-02-22-23-50-00.gh-issue-145119.hN4sQ8.rst new file mode 100644 index 00000000000000..5eaf620c38d880 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-02-22-23-50-00.gh-issue-145119.hN4sQ8.rst @@ -0,0 +1,2 @@ +Allow :class:`frozendict` to be assigned to an instance's +:attr:`~object.__dict__`, enabling immutable instances. diff --git a/Objects/dictobject.c b/Objects/dictobject.c index 6c802ca569d48c..545f771e682842 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -2743,6 +2743,11 @@ int PyDict_SetItem(PyObject *op, PyObject *key, PyObject *value) { if (!PyDict_Check(op)) { + if (PyFrozenDict_Check(op)) { + PyErr_SetString(PyExc_TypeError, + "'frozendict' object does not support item assignment"); + return -1; + } PyErr_BadInternalCall(); return -1; } @@ -2883,6 +2888,11 @@ _PyDict_DelItem_KnownHash_LockHeld(PyObject *op, PyObject *key, Py_hash_t hash) PyObject *old_value; if (!PyDict_Check(op)) { + if (PyFrozenDict_Check(op)) { + PyErr_SetString(PyExc_TypeError, + "'frozendict' object does not support item deletion"); + return -1; + } PyErr_BadInternalCall(); return -1; } @@ -7064,6 +7074,17 @@ int _PyDict_SetItem_LockHeld(PyDictObject *dict, PyObject *name, PyObject *value) { if (!PyDict_Check(dict)) { + if (PyFrozenDict_Check((PyObject *)dict)) { + if (value == NULL) { + PyErr_SetString(PyExc_TypeError, + "'frozendict' object does not support item deletion"); + } + else { + PyErr_SetString(PyExc_TypeError, + "'frozendict' object does not support item assignment"); + } + return -1; + } PyErr_BadInternalCall(); return -1; } diff --git a/Objects/typeobject.c b/Objects/typeobject.c index ad26339c9c34df..d5f6db77af6ef0 100644 --- a/Objects/typeobject.c +++ b/Objects/typeobject.c @@ -3999,7 +3999,7 @@ subtype_dict(PyObject *obj, void *context) int _PyObject_SetDict(PyObject *obj, PyObject *value) { - if (value != NULL && !PyDict_Check(value)) { + if (value != NULL && !PyAnyDict_Check(value)) { PyErr_Format(PyExc_TypeError, "__dict__ must be set to a dictionary, " "not a '%.200s'", Py_TYPE(value)->tp_name); @@ -8305,15 +8305,24 @@ object___dir___impl(PyObject *self) if (dict == NULL) { dict = PyDict_New(); } - else if (!PyDict_Check(dict)) { - Py_DECREF(dict); - dict = PyDict_New(); - } - else { + else if (PyDict_Check(dict)) { /* Copy __dict__ to avoid mutating it. */ PyObject *temp = PyDict_Copy(dict); Py_SETREF(dict, temp); } + else if (PyFrozenDict_Check(dict)) { + /* Convert frozendict to a mutable dict for merging. */ + PyObject *temp = PyDict_New(); + if (temp != NULL && PyDict_Update(temp, dict) < 0) { + Py_DECREF(temp); + temp = NULL; + } + Py_SETREF(dict, temp); + } + else { + Py_DECREF(dict); + dict = PyDict_New(); + } if (dict == NULL) goto error;