Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions Lib/test/test_capi/test_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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')
Expand All @@ -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')
Expand Down
39 changes: 39 additions & 0 deletions Lib/test/test_descr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Allow :class:`frozendict` to be assigned to an instance's
:attr:`~object.__dict__`, enabling immutable instances.
21 changes: 21 additions & 0 deletions Objects/dictobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
21 changes: 15 additions & 6 deletions Objects/typeobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid frozendict dict on objects mutating their own dict

Allowing _PyObject_SetDict() to accept PyAnyDict_Check here makes frozendict assignable to every PyObject_GenericSetDict user, but some of those types still perform internal writes with PyDict_SetItem/PyDict_DelItem (for example descriptor_get_wrapped_attribute and descriptor_set_wrapped_attribute in Objects/funcobject.c for classmethod/staticmethod annotation caching). PyDict_SetItem still rejects frozendict with PyErr_BadInternalCall, so after obj.__dict__ = frozendict(...), operations like reading or updating __annotations__ can now raise SystemError instead of behaving normally or reporting an intentional immutability error.

Useful? React with 👍 / 👎.

PyErr_Format(PyExc_TypeError,
"__dict__ must be set to a dictionary, "
"not a '%.200s'", Py_TYPE(value)->tp_name);
Expand Down Expand Up @@ -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;
Expand Down
Loading