From bde437926b1e9799f5eae692779f8197b70640e3 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Thu, 12 Feb 2026 18:27:34 +0100 Subject: [PATCH 01/10] gh-141510, PEP 814: Add built-in frozendict type Add TYPE_FROZENDICT to the marshal module. Add C API functions: * PyAnyDict_Check() * PyAnyDict_CheckExact() * PyFrozenDict_Check() * PyFrozenDict_CheckExact() * PyFrozenDict_New() Add PyFrozenDict_Type C type. --- Doc/c-api/dict.rst | 40 +++ Doc/library/stdtypes.rst | 19 +- Doc/whatsnew/3.15.rst | 26 ++ Include/cpython/dictobject.h | 15 +- Include/internal/pycore_dict.h | 9 + Include/internal/pycore_typeobject.h | 1 + Lib/_collections_abc.py | 1 + Lib/test/mapping_tests.py | 129 +++---- Lib/test/test_dict.py | 43 +++ ...-02-12-19-03-31.gh-issue-141510.U_1tjz.rst | 9 + ...-02-12-19-01-13.gh-issue-141510.KlKjZg.rst | 1 + Objects/dictobject.c | 318 +++++++++++++++--- Objects/object.c | 5 +- Python/bltinmodule.c | 1 + Python/marshal.c | 22 +- Tools/c-analyzer/cpython/ignored.tsv | 1 + 16 files changed, 523 insertions(+), 117 deletions(-) create mode 100644 Misc/NEWS.d/next/C_API/2026-02-12-19-03-31.gh-issue-141510.U_1tjz.rst create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-02-12-19-01-13.gh-issue-141510.KlKjZg.rst diff --git a/Doc/c-api/dict.rst b/Doc/c-api/dict.rst index 9c4428ced41b5a..85f4bb8305e977 100644 --- a/Doc/c-api/dict.rst +++ b/Doc/c-api/dict.rst @@ -490,6 +490,46 @@ Dictionary View Objects always succeeds. +Frozen Dictionary Objects +^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. versionadded:: next + + +.. c:function:: int PyAnyDict_Check(PyObject *p) + + Return true if *p* is a dict object, a frozendict object, or an instance of + a subtype of the dict or frozendict type. + This function always succeeds. + + +.. c:function:: int PyAnyDict_CheckExact(PyObject *p) + + Return true if *p* is a dict object or a frozendict object, but not an + instance of a subtype of the dict or frozendict type. + This function always succeeds. + + +.. c:function:: int PyFrozenDict_Check(PyObject *p) + + Return true if *p* is a frozendict object or an instance of a subtype of the + frozendict type. + This function always succeeds. + + +.. c:function:: int PyFrozenDict_CheckExact(PyObject *p) + + Return true if *p* is a frozendict object, but not an instance of a subtype + of the frozendict type. + This function always succeeds. + + +.. c:function:: PyObject* PyFrozenDict_New(PyObject *iterable) + + Return a new frozendict from an iterable, or ``NULL`` on failure with an + exception set. + + Ordered Dictionaries ^^^^^^^^^^^^^^^^^^^^ diff --git a/Doc/library/stdtypes.rst b/Doc/library/stdtypes.rst index d4540e0b819871..acc1bd201f82b8 100644 --- a/Doc/library/stdtypes.rst +++ b/Doc/library/stdtypes.rst @@ -5305,8 +5305,8 @@ frozenset, a temporary one is created from *elem*. .. _typesmapping: -Mapping Types --- :class:`dict` -=============================== +Mapping Types --- :class:`dict`, :class:`frozendict` +==================================================== .. index:: pair: object; mapping @@ -5317,8 +5317,9 @@ Mapping Types --- :class:`dict` pair: built-in function; len A :term:`mapping` object maps :term:`hashable` values to arbitrary objects. -Mappings are mutable objects. There is currently only one standard mapping -type, the :dfn:`dictionary`. (For other containers see the built-in +There are currently two standard mapping types, the :dfn:`dictionary` and +:class:`frozendict`. +(For other containers see the built-in :class:`list`, :class:`set`, and :class:`tuple` classes, and the :mod:`collections` module.) @@ -5587,6 +5588,15 @@ can be used interchangeably to index the same dictionary entry. .. versionchanged:: 3.8 Dictionaries are now reversible. +.. class:: frozendict(**kwargs) + frozendict(mapping, /, **kwargs) + frozendict(iterable, /, **kwargs) + + Return a new frozen dictionary initialized from an optional positional + argument and a possibly empty set of keyword arguments. + + .. versionadded:: next + .. seealso:: :class:`types.MappingProxyType` can be used to create a read-only view @@ -6062,6 +6072,7 @@ list is non-exhaustive. * :class:`list` * :class:`dict` * :class:`set` +* :class:`frozendict` * :class:`frozenset` * :class:`type` * :class:`asyncio.Future` diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 0e440ccfd011f0..3c99339cf0843b 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -65,6 +65,8 @@ Summary -- Release highlights .. PEP-sized items next. +* :pep:`814`: :ref:`Add frozendict built-in type + ` * :pep:`810`: :ref:`Explicit lazy imports for faster startup times ` * :pep:`799`: :ref:`A dedicated profiling package for organizing Python @@ -84,6 +86,20 @@ Summary -- Release highlights New features ============ +.. _whatsnew315-pep814: + +:pep:`814`: Add frozendict built-in type +---------------------------------------- + +A new public immutable type :class:`frozendict` is added to the :mod:`builtins` +module. It is not a ``dict`` subclass but inherits directly from ``object``. + +A ``frozendict`` can be hashed with ``hash(frozendict)`` if all keys and values +can be hashed. + +.. seealso:: :pep:`814` for the full specification and rationale. + + .. _whatsnew315-pep810: :pep:`810`: Explicit lazy imports @@ -1512,6 +1528,16 @@ C API changes New features ------------ +* Add the following functions for the new :class:`frozendict` type: + + * :c:func:`PyAnyDict_Check` + * :c:func:`PyAnyDict_CheckExact` + * :c:func:`PyFrozenDict_Check` + * :c:func:`PyFrozenDict_CheckExact` + * :c:func:`PyFrozenDict_New` + + (Contributed by Victor Stinner in :gh:`141510`.) + * Add :c:func:`PySys_GetAttr`, :c:func:`PySys_GetAttrString`, :c:func:`PySys_GetOptionalAttr`, and :c:func:`PySys_GetOptionalAttrString` functions as replacements for :c:func:`PySys_GetObject`. diff --git a/Include/cpython/dictobject.h b/Include/cpython/dictobject.h index 5f2f7b6d4f56bd..5e7811416aba63 100644 --- a/Include/cpython/dictobject.h +++ b/Include/cpython/dictobject.h @@ -32,6 +32,16 @@ typedef struct { PyDictValues *ma_values; } PyDictObject; +// frozendict +PyAPI_DATA(PyTypeObject) PyFrozenDict_Type; +#define PyFrozenDict_Check(op) PyObject_TypeCheck((op), &PyFrozenDict_Type) +#define PyFrozenDict_CheckExact(op) Py_IS_TYPE((op), &PyFrozenDict_Type) + +#define PyAnyDict_CheckExact(ob) \ + (PyDict_CheckExact(ob) || PyFrozenDict_CheckExact(ob)) +#define PyAnyDict_Check(ob) \ + (PyDict_Check(ob) || PyFrozenDict_Check(ob)) + PyAPI_FUNC(PyObject *) _PyDict_GetItem_KnownHash(PyObject *mp, PyObject *key, Py_hash_t hash); // PyDict_GetItemStringRef() can be used instead @@ -42,7 +52,7 @@ PyAPI_FUNC(PyObject *) PyDict_SetDefault( /* Get the number of items of a dictionary. */ static inline Py_ssize_t PyDict_GET_SIZE(PyObject *op) { PyDictObject *mp; - assert(PyDict_Check(op)); + assert(PyAnyDict_Check(op)); mp = _Py_CAST(PyDictObject*, op); #ifdef Py_GIL_DISABLED return _Py_atomic_load_ssize_relaxed(&mp->ma_used); @@ -93,3 +103,6 @@ PyAPI_FUNC(int) PyDict_ClearWatcher(int watcher_id); // Mark given dictionary as "watched" (callback will be called if it is modified) PyAPI_FUNC(int) PyDict_Watch(int watcher_id, PyObject* dict); PyAPI_FUNC(int) PyDict_Unwatch(int watcher_id, PyObject* dict); + +// Create a frozendict. Create an empty dictionary if iterable is NULL. +PyAPI_FUNC(PyObject*) PyFrozenDict_New(PyObject *iterable); diff --git a/Include/internal/pycore_dict.h b/Include/internal/pycore_dict.h index 379bf6a81784b0..59e88be6aeec12 100644 --- a/Include/internal/pycore_dict.h +++ b/Include/internal/pycore_dict.h @@ -408,6 +408,15 @@ _Py_DECREF_BUILTINS(PyObject *op) } #endif +/* frozendict */ +typedef struct { + PyDictObject ob_base; + Py_hash_t ma_hash; +} PyFrozenDictObject; + +#define _PyFrozenDictObject_CAST(op) \ + (assert(PyFrozenDict_Check(op)), _Py_CAST(PyFrozenDictObject*, (op))) + #ifdef __cplusplus } #endif diff --git a/Include/internal/pycore_typeobject.h b/Include/internal/pycore_typeobject.h index dfd355d5012066..8af317d54c0bda 100644 --- a/Include/internal/pycore_typeobject.h +++ b/Include/internal/pycore_typeobject.h @@ -26,6 +26,7 @@ extern "C" { #define _Py_TYPE_VERSION_BYTEARRAY 9 #define _Py_TYPE_VERSION_BYTES 10 #define _Py_TYPE_VERSION_COMPLEX 11 +#define _Py_TYPE_VERSION_FROZENDICT 12 #define _Py_TYPE_VERSION_NEXT 16 diff --git a/Lib/_collections_abc.py b/Lib/_collections_abc.py index 60b471317ce97c..23cc6d8faae2da 100644 --- a/Lib/_collections_abc.py +++ b/Lib/_collections_abc.py @@ -823,6 +823,7 @@ def __eq__(self, other): __reversed__ = None +Mapping.register(frozendict) Mapping.register(mappingproxy) Mapping.register(framelocalsproxy) diff --git a/Lib/test/mapping_tests.py b/Lib/test/mapping_tests.py index 20306e1526d7b8..ccc1268bdff1df 100644 --- a/Lib/test/mapping_tests.py +++ b/Lib/test/mapping_tests.py @@ -4,7 +4,7 @@ from test import support -class BasicTestMappingProtocol(unittest.TestCase): +class BasicTestImmutableMappingProtocol(unittest.TestCase): # This base class can be used to check that an object conforms to the # mapping protocol @@ -22,10 +22,7 @@ def _empty_mapping(self): def _full_mapping(self, data): """Return a mapping object with the value contained in data dictionary""" - x = self._empty_mapping() - for key, value in data.items(): - x[key] = value - return x + return self.type2test(data) def __init__(self, *args, **kw): unittest.TestCase.__init__(self, *args, **kw) @@ -88,6 +85,72 @@ def check_iterandlist(iter, lst, ref): self.assertEqual(d.get(knownkey, knownvalue), knownvalue) self.assertNotIn(knownkey, d) + def test_constructor(self): + self.assertEqual(self._empty_mapping(), self._empty_mapping()) + + def test_bool(self): + self.assertTrue(not self._empty_mapping()) + self.assertTrue(self.reference) + self.assertTrue(bool(self._empty_mapping()) is False) + self.assertTrue(bool(self.reference) is True) + + def test_keys(self): + d = self._empty_mapping() + self.assertEqual(list(d.keys()), []) + d = self.reference + self.assertIn(list(self.inmapping.keys())[0], d.keys()) + self.assertNotIn(list(self.other.keys())[0], d.keys()) + self.assertRaises(TypeError, d.keys, None) + + def test_values(self): + d = self._empty_mapping() + self.assertEqual(list(d.values()), []) + + self.assertRaises(TypeError, d.values, None) + + def test_items(self): + d = self._empty_mapping() + self.assertEqual(list(d.items()), []) + + self.assertRaises(TypeError, d.items, None) + + def test_len(self): + d = self._empty_mapping() + self.assertEqual(len(d), 0) + + def test_getitem(self): + d = self.reference + self.assertEqual(d[list(self.inmapping.keys())[0]], + list(self.inmapping.values())[0]) + + self.assertRaises(TypeError, d.__getitem__) + + # no test_fromkeys or test_copy as both os.environ and selves don't support it + + def test_get(self): + d = self._empty_mapping() + self.assertTrue(d.get(list(self.other.keys())[0]) is None) + self.assertEqual(d.get(list(self.other.keys())[0], 3), 3) + d = self.reference + self.assertTrue(d.get(list(self.other.keys())[0]) is None) + self.assertEqual(d.get(list(self.other.keys())[0], 3), 3) + self.assertEqual(d.get(list(self.inmapping.keys())[0]), + list(self.inmapping.values())[0]) + self.assertEqual(d.get(list(self.inmapping.keys())[0], 3), + list(self.inmapping.values())[0]) + self.assertRaises(TypeError, d.get) + self.assertRaises(TypeError, d.get, None, None, None) + + +class BasicTestMappingProtocol(BasicTestImmutableMappingProtocol): + def _full_mapping(self, data): + """Return a mapping object with the value contained in data + dictionary""" + x = self._empty_mapping() + for key, value in data.items(): + x[key] = value + return x + def test_write(self): # Test for write operations on mapping p = self._empty_mapping() @@ -130,46 +193,6 @@ def test_write(self): p=self._empty_mapping() self.assertRaises(KeyError, p.popitem) - def test_constructor(self): - self.assertEqual(self._empty_mapping(), self._empty_mapping()) - - def test_bool(self): - self.assertTrue(not self._empty_mapping()) - self.assertTrue(self.reference) - self.assertTrue(bool(self._empty_mapping()) is False) - self.assertTrue(bool(self.reference) is True) - - def test_keys(self): - d = self._empty_mapping() - self.assertEqual(list(d.keys()), []) - d = self.reference - self.assertIn(list(self.inmapping.keys())[0], d.keys()) - self.assertNotIn(list(self.other.keys())[0], d.keys()) - self.assertRaises(TypeError, d.keys, None) - - def test_values(self): - d = self._empty_mapping() - self.assertEqual(list(d.values()), []) - - self.assertRaises(TypeError, d.values, None) - - def test_items(self): - d = self._empty_mapping() - self.assertEqual(list(d.items()), []) - - self.assertRaises(TypeError, d.items, None) - - def test_len(self): - d = self._empty_mapping() - self.assertEqual(len(d), 0) - - def test_getitem(self): - d = self.reference - self.assertEqual(d[list(self.inmapping.keys())[0]], - list(self.inmapping.values())[0]) - - self.assertRaises(TypeError, d.__getitem__) - def test_update(self): # mapping argument d = self._empty_mapping() @@ -265,22 +288,6 @@ def __next__(self): self.assertRaises(ValueError, d.update, [(1, 2, 3)]) - # no test_fromkeys or test_copy as both os.environ and selves don't support it - - def test_get(self): - d = self._empty_mapping() - self.assertTrue(d.get(list(self.other.keys())[0]) is None) - self.assertEqual(d.get(list(self.other.keys())[0], 3), 3) - d = self.reference - self.assertTrue(d.get(list(self.other.keys())[0]) is None) - self.assertEqual(d.get(list(self.other.keys())[0], 3), 3) - self.assertEqual(d.get(list(self.inmapping.keys())[0]), - list(self.inmapping.values())[0]) - self.assertEqual(d.get(list(self.inmapping.keys())[0], 3), - list(self.inmapping.values())[0]) - self.assertRaises(TypeError, d.get) - self.assertRaises(TypeError, d.get, None, None, None) - def test_setdefault(self): d = self._empty_mapping() self.assertRaises(TypeError, d.setdefault) diff --git a/Lib/test/test_dict.py b/Lib/test/test_dict.py index 6583c0f2aefb2b..78e8f6bed0e2c0 100644 --- a/Lib/test/test_dict.py +++ b/Lib/test/test_dict.py @@ -1723,6 +1723,49 @@ class Dict(dict): class SubclassMappingTests(mapping_tests.BasicTestMappingProtocol): type2test = Dict +class FrozenDictMappingTests(mapping_tests.BasicTestImmutableMappingProtocol): + type2test = frozendict + + +class FrozenDict(frozendict): + pass + + +class FrozenDictTests(unittest.TestCase): + def test_copy(self): + d = frozendict(x=1, y=2) + d2 = d.copy() + self.assertIs(d2, d) + + d = FrozenDict(x=1, y=2) + d2 = d.copy() + self.assertIsNot(d2, d) + self.assertEqual(d2, frozendict(x=1, y=2)) + self.assertEqual(type(d2), frozendict) + + def test_merge(self): + # test "a | b" operator + self.assertEqual(frozendict(x=1) | frozendict(y=2), + frozendict({'x': 1, 'y': 2})) + self.assertEqual(frozendict(x=1) | dict(y=2), + frozendict({'x': 1, 'y': 2})) + self.assertEqual(frozendict(x=1, y=2) | frozendict(y=5), + frozendict({'x': 1, 'y': 5})) + fd = frozendict(x=1, y=2) + self.assertIs(fd | frozendict(), fd) + self.assertIs(fd | {}, fd) + self.assertIs(frozendict() | fd, fd) + + def test_update(self): + # test "a |= b" operator + d = frozendict(x=1) + copy = d + self.assertIs(copy, d) + d |= frozendict(y=2) + self.assertIsNot(copy, d) + self.assertEqual(d, frozendict({'x': 1, 'y': 2})) + self.assertEqual(copy, frozendict({'x': 1})) + if __name__ == "__main__": unittest.main() diff --git a/Misc/NEWS.d/next/C_API/2026-02-12-19-03-31.gh-issue-141510.U_1tjz.rst b/Misc/NEWS.d/next/C_API/2026-02-12-19-03-31.gh-issue-141510.U_1tjz.rst new file mode 100644 index 00000000000000..57a25fe045f04c --- /dev/null +++ b/Misc/NEWS.d/next/C_API/2026-02-12-19-03-31.gh-issue-141510.U_1tjz.rst @@ -0,0 +1,9 @@ +Add the following functions for the new :class:`frozendict` type: + +* :c:func:`PyAnyDict_Check` +* :c:func:`PyAnyDict_CheckExact` +* :c:func:`PyFrozenDict_Check` +* :c:func:`PyFrozenDict_CheckExact` +* :c:func:`PyFrozenDict_New` + +Patch by Victor Stinner. diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-02-12-19-01-13.gh-issue-141510.KlKjZg.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-02-12-19-01-13.gh-issue-141510.KlKjZg.rst new file mode 100644 index 00000000000000..4596e273fc6118 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-02-12-19-01-13.gh-issue-141510.KlKjZg.rst @@ -0,0 +1 @@ +Add built-in :class:`frozendict` type. Patch by Victor Stinner. diff --git a/Objects/dictobject.c b/Objects/dictobject.c index ae7bf61767dc3b..c7619b6fd14619 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -135,6 +135,10 @@ As a consequence of this, split keys have a maximum size of 16. #include "stringlib/eq.h" // unicode_eq() #include +// Forward declarations +static PyObject* frozendict_new(PyTypeObject *type, PyObject *args, + PyObject *kwds); + /*[clinic input] class dict "PyDictObject *" "&PyDict_Type" @@ -278,6 +282,11 @@ load_keys_nentries(PyDictObject *mp) #endif +#define _PyAnyDict_CAST(op) \ + (assert(PyAnyDict_Check(op)), _Py_CAST(PyDictObject*, op)) + +#define GET_USED(ep) FT_ATOMIC_LOAD_SSIZE_RELAXED((ep)->ma_used) + #define STORE_KEY(ep, key) FT_ATOMIC_STORE_PTR_RELEASE((ep)->me_key, key) #define STORE_VALUE(ep, value) FT_ATOMIC_STORE_PTR_RELEASE((ep)->me_value, value) #define STORE_SPLIT_VALUE(mp, idx, value) FT_ATOMIC_STORE_PTR_RELEASE(mp->ma_values->values[idx], value) @@ -654,7 +663,7 @@ _PyDict_CheckConsistency(PyObject *op, int check_content) do { if (!(expr)) { _PyObject_ASSERT_FAILED_MSG(op, Py_STRINGIFY(expr)); } } while (0) assert(op != NULL); - CHECK(PyDict_Check(op)); + CHECK(PyAnyDict_Check(op)); PyDictObject *mp = (PyDictObject *)op; PyDictKeysObject *keys = mp->ma_keys; @@ -909,7 +918,7 @@ new_dict_with_shared_keys(PyDictKeysObject *keys) static PyDictKeysObject * clone_combined_dict_keys(PyDictObject *orig) { - assert(PyDict_Check(orig)); + assert(PyAnyDict_Check(orig)); assert(Py_TYPE(orig)->tp_iter == dict_iter); assert(orig->ma_values == NULL); assert(orig->ma_keys != Py_EMPTY_KEYS); @@ -2293,7 +2302,7 @@ _PyDict_FromItems(PyObject *const *keys, Py_ssize_t keys_offset, static PyObject * dict_getitem(PyObject *op, PyObject *key, const char *warnmsg) { - if (!PyDict_Check(op)) { + if (!PyAnyDict_Check(op)) { return NULL; } PyDictObject *mp = (PyDictObject *)op; @@ -2392,7 +2401,7 @@ _PyDict_GetItem_KnownHash(PyObject *op, PyObject *key, Py_hash_t hash) PyDictObject *mp = (PyDictObject *)op; PyObject *value; - if (!PyDict_Check(op)) { + if (!PyAnyDict_Check(op)) { PyErr_BadInternalCall(); return NULL; } @@ -2463,7 +2472,7 @@ _PyDict_GetItemRef_KnownHash(PyDictObject *op, PyObject *key, Py_hash_t hash, Py int PyDict_GetItemRef(PyObject *op, PyObject *key, PyObject **result) { - if (!PyDict_Check(op)) { + if (!PyAnyDict_Check(op)) { PyErr_BadInternalCall(); *result = NULL; return -1; @@ -2519,7 +2528,7 @@ PyDict_GetItemWithError(PyObject *op, PyObject *key) PyDictObject*mp = (PyDictObject *)op; PyObject *value; - if (!PyDict_Check(op)) { + if (!PyAnyDict_Check(op)) { PyErr_BadInternalCall(); return NULL; } @@ -2629,7 +2638,7 @@ _PyDict_LoadGlobalStackRef(PyDictObject *globals, PyDictObject *builtins, PyObje PyObject * _PyDict_LoadBuiltinsFromGlobals(PyObject *globals) { - if (!PyDict_Check(globals)) { + if (!PyAnyDict_Check(globals)) { PyErr_BadInternalCall(); return NULL; } @@ -2668,7 +2677,7 @@ setitem_take2_lock_held(PyDictObject *mp, PyObject *key, PyObject *value) assert(key); assert(value); - assert(PyDict_Check(mp)); + assert(PyAnyDict_Check(mp)); Py_hash_t hash = _PyObject_HashFast(key); if (hash == -1) { dict_unhashable_type(key); @@ -2713,6 +2722,16 @@ PyDict_SetItem(PyObject *op, PyObject *key, PyObject *value) Py_NewRef(key), Py_NewRef(value)); } +static int +_PyAnyDict_SetItem(PyObject *op, PyObject *key, PyObject *value) +{ + assert(PyAnyDict_Check(op)); + assert(key); + assert(value); + return _PyDict_SetItem_Take2((PyDictObject *)op, + Py_NewRef(key), Py_NewRef(value)); +} + static int setitem_lock_held(PyDictObject *mp, PyObject *key, PyObject *value) { @@ -2996,7 +3015,7 @@ _PyDict_Next(PyObject *op, Py_ssize_t *ppos, PyObject **pkey, PyObject *key, *value; Py_hash_t hash; - if (!PyDict_Check(op)) + if (!PyAnyDict_Check(op)) return 0; mp = (PyDictObject *)op; @@ -3265,8 +3284,8 @@ _PyDict_FromKeys(PyObject *cls, PyObject *iterable, PyObject *value) return NULL; - if (PyDict_CheckExact(d)) { - if (PyDict_CheckExact(iterable)) { + if (PyAnyDict_CheckExact(d)) { + if (PyAnyDict_CheckExact(iterable)) { PyDictObject *mp = (PyDictObject *)d; Py_BEGIN_CRITICAL_SECTION2(d, iterable); @@ -3290,7 +3309,7 @@ _PyDict_FromKeys(PyObject *cls, PyObject *iterable, PyObject *value) return NULL; } - if (PyDict_CheckExact(d)) { + if (PyAnyDict_CheckExact(d)) { Py_BEGIN_CRITICAL_SECTION(d); while ((key = PyIter_Next(it)) != NULL) { status = setitem_lock_held((PyDictObject *)d, key, value); @@ -3460,7 +3479,7 @@ dict_repr(PyObject *self) static Py_ssize_t dict_length(PyObject *self) { - return FT_ATOMIC_LOAD_SSIZE_RELAXED(((PyDictObject *)self)->ma_used); + return GET_USED(_PyAnyDict_CAST(self)); } static PyObject * @@ -3480,7 +3499,7 @@ dict_subscript(PyObject *self, PyObject *key) if (ix == DKIX_ERROR) return NULL; if (ix == DKIX_EMPTY || value == NULL) { - if (!PyDict_CheckExact(mp)) { + if (!PyAnyDict_CheckExact(mp)) { /* Look up __missing__ method if we're a subclass. */ PyObject *missing, *res; missing = _PyObject_LookupSpecial( @@ -3519,7 +3538,7 @@ keys_lock_held(PyObject *dict) { ASSERT_DICT_LOCKED(dict); - if (dict == NULL || !PyDict_Check(dict)) { + if (dict == NULL || !PyAnyDict_Check(dict)) { PyErr_BadInternalCall(); return NULL; } @@ -3568,7 +3587,7 @@ values_lock_held(PyObject *dict) { ASSERT_DICT_LOCKED(dict); - if (dict == NULL || !PyDict_Check(dict)) { + if (dict == NULL || !PyAnyDict_Check(dict)) { PyErr_BadInternalCall(); return NULL; } @@ -3616,7 +3635,7 @@ items_lock_held(PyObject *dict) { ASSERT_DICT_LOCKED(dict); - if (dict == NULL || !PyDict_Check(dict)) { + if (dict == NULL || !PyAnyDict_Check(dict)) { PyErr_BadInternalCall(); return NULL; } @@ -3696,7 +3715,7 @@ dict_fromkeys_impl(PyTypeObject *type, PyObject *iterable, PyObject *value) static int dict_update_arg(PyObject *self, PyObject *arg) { - if (PyDict_CheckExact(arg)) { + if (PyAnyDict_CheckExact(arg)) { return PyDict_Merge(self, arg, 1); } int has_keys = PyObject_HasAttrWithError(arg, &_Py_ID(keys)); @@ -3762,7 +3781,7 @@ merge_from_seq2_lock_held(PyObject *d, PyObject *seq2, int override) PyObject *fast; /* item as a 2-tuple or 2-list */ assert(d != NULL); - assert(PyDict_Check(d)); + assert(PyAnyDict_Check(d)); assert(seq2 != NULL); it = PyObject_GetIter(seq2); @@ -3958,13 +3977,13 @@ dict_merge(PyObject *a, PyObject *b, int override) * things quite efficiently. For the latter, we only require that * PyMapping_Keys() and PyObject_GetItem() be supported. */ - if (a == NULL || !PyDict_Check(a) || b == NULL) { + if (a == NULL || !PyAnyDict_Check(a) || b == NULL) { PyErr_BadInternalCall(); return -1; } mp = (PyDictObject*)a; int res = 0; - if (PyDict_Check(b) && (Py_TYPE(b)->tp_iter == dict_iter)) { + if (PyAnyDict_Check(b) && (Py_TYPE(b)->tp_iter == dict_iter)) { other = (PyDictObject*)b; int res; Py_BEGIN_CRITICAL_SECTION2(a, b); @@ -4075,6 +4094,9 @@ static PyObject * dict_copy_impl(PyDictObject *self) /*[clinic end generated code: output=ffb782cf970a5c39 input=73935f042b639de4]*/ { + if (PyFrozenDict_CheckExact(self)) { + return Py_NewRef(self); + } return PyDict_Copy((PyObject *)self); } @@ -4104,13 +4126,19 @@ copy_lock_held(PyObject *o) { PyObject *copy; PyDictObject *mp; + int frozendict = PyFrozenDict_Check(o); ASSERT_DICT_LOCKED(o); mp = (PyDictObject *)o; if (mp->ma_used == 0) { /* The dict is empty; just return a new dict. */ - return PyDict_New(); + if (frozendict) { + return PyFrozenDict_New(NULL); + } + else { + return PyDict_New(); + } } if (_PyDict_HasSplitTable(mp)) { @@ -4119,7 +4147,13 @@ copy_lock_held(PyObject *o) if (newvalues == NULL) { return PyErr_NoMemory(); } - split_copy = PyObject_GC_New(PyDictObject, &PyDict_Type); + if (frozendict) { + split_copy = (PyDictObject *)PyObject_GC_New(PyFrozenDictObject, + &PyFrozenDict_Type); + } + else { + split_copy = PyObject_GC_New(PyDictObject, &PyDict_Type); + } if (split_copy == NULL) { free_values(newvalues, false); return NULL; @@ -4132,13 +4166,18 @@ copy_lock_held(PyObject *o) split_copy->ma_used = mp->ma_used; split_copy->_ma_watcher_tag = 0; dictkeys_incref(mp->ma_keys); + if (frozendict) { + PyFrozenDictObject *frozen = (PyFrozenDictObject *)split_copy; + frozen->ma_hash = -1; + } _PyObject_GC_TRACK(split_copy); return (PyObject *)split_copy; } if (Py_TYPE(mp)->tp_iter == dict_iter && mp->ma_values == NULL && - (mp->ma_used >= (mp->ma_keys->dk_nentries * 2) / 3)) + (mp->ma_used >= (mp->ma_keys->dk_nentries * 2) / 3) && + !frozendict) { /* Use fast-copy if: @@ -4170,7 +4209,12 @@ copy_lock_held(PyObject *o) return (PyObject *)new; } - copy = PyDict_New(); + if (frozendict) { + copy = PyFrozenDict_New(NULL); + } + else { + copy = PyDict_New(); + } if (copy == NULL) return NULL; if (dict_merge(copy, o, 1) == 0) @@ -4182,7 +4226,7 @@ copy_lock_held(PyObject *o) PyObject * PyDict_Copy(PyObject *o) { - if (o == NULL || !PyDict_Check(o)) { + if (o == NULL || !PyAnyDict_Check(o)) { PyErr_BadInternalCall(); return NULL; } @@ -4199,11 +4243,11 @@ PyDict_Copy(PyObject *o) Py_ssize_t PyDict_Size(PyObject *mp) { - if (mp == NULL || !PyDict_Check(mp)) { + if (mp == NULL || !PyAnyDict_Check(mp)) { PyErr_BadInternalCall(); return -1; } - return FT_ATOMIC_LOAD_SSIZE_RELAXED(((PyDictObject *)mp)->ma_used); + return GET_USED((PyDictObject *)mp); } /* Return 1 if dicts equal, 0 if not, -1 if error. @@ -4289,7 +4333,7 @@ dict_richcompare(PyObject *v, PyObject *w, int op) int cmp; PyObject *res; - if (!PyDict_Check(v) || !PyDict_Check(w)) { + if (!PyAnyDict_Check(v) || !PyAnyDict_Check(w)) { res = Py_NotImplemented; } else if (op == Py_EQ || op == Py_NE) { @@ -4739,7 +4783,7 @@ dict___sizeof___impl(PyDictObject *self) static PyObject * dict_or(PyObject *self, PyObject *other) { - if (!PyDict_Check(self) || !PyDict_Check(other)) { + if (!PyAnyDict_Check(self) || !PyAnyDict_Check(other)) { Py_RETURN_NOTIMPLEMENTED; } PyObject *new = PyDict_Copy(self); @@ -4753,6 +4797,29 @@ dict_or(PyObject *self, PyObject *other) return new; } +static PyObject * +frozendict_or(PyObject *self, PyObject *other) +{ + if (PyFrozenDict_CheckExact(self)) { + // frozendict() | frozendict(...) => frozendict(...) + if (GET_USED((PyDictObject *)self) == 0 + && PyFrozenDict_CheckExact(other)) + { + return Py_NewRef(other); + } + + // frozendict(...) | frozendict() => frozendict(...) + if (PyAnyDict_CheckExact(other) + && GET_USED((PyDictObject *)other) == 0) + { + return Py_NewRef(self); + } + } + + return dict_or(self, other); +} + + static PyObject * dict_ior(PyObject *self, PyObject *other) { @@ -4905,7 +4972,15 @@ dict_vectorcall(PyObject *type, PyObject * const*args, return NULL; } - PyObject *self = dict_new(_PyType_CAST(type), NULL, NULL); + PyObject *self; + if (Py_Is((PyTypeObject*)type, &PyFrozenDict_Type) + || PyType_IsSubtype((PyTypeObject*)type, &PyFrozenDict_Type)) + { + self = frozendict_new(_PyType_CAST(type), NULL, NULL); + } + else { + self = dict_new(_PyType_CAST(type), NULL, NULL); + } if (self == NULL) { return NULL; } @@ -4918,7 +4993,8 @@ dict_vectorcall(PyObject *type, PyObject * const*args, } if (kwnames != NULL) { for (Py_ssize_t i = 0; i < PyTuple_GET_SIZE(kwnames); i++) { - if (PyDict_SetItem(self, PyTuple_GET_ITEM(kwnames, i), args[i]) < 0) { + PyObject *key = PyTuple_GET_ITEM(kwnames, i); // borrowed + if (_PyAnyDict_SetItem(self, key, args[i]) < 0) { Py_DECREF(self); return NULL; } @@ -4991,6 +5067,7 @@ PyTypeObject PyDict_Type = { .tp_version_tag = _Py_TYPE_VERSION_DICT, }; + /* For backward compatibility with old dictionary interface */ PyObject * @@ -5073,7 +5150,7 @@ dictiter_new(PyDictObject *dict, PyTypeObject *itertype) return NULL; } di->di_dict = (PyDictObject*)Py_NewRef(dict); - used = FT_ATOMIC_LOAD_SSIZE_RELAXED(dict->ma_used); + used = GET_USED(dict); di->di_used = used; di->len = used; if (itertype == &PyDictRevIterKey_Type || @@ -5129,7 +5206,7 @@ dictiter_len(PyObject *self, PyObject *Py_UNUSED(ignored)) { dictiterobject *di = (dictiterobject *)self; Py_ssize_t len = 0; - if (di->di_dict != NULL && di->di_used == FT_ATOMIC_LOAD_SSIZE_RELAXED(di->di_dict->ma_used)) + if (di->di_dict != NULL && di->di_used == GET_USED(di->di_dict)) len = FT_ATOMIC_LOAD_SSIZE_RELAXED(di->len); return PyLong_FromSize_t(len); } @@ -5166,7 +5243,7 @@ dictiter_iternextkey_lock_held(PyDictObject *d, PyObject *self) Py_ssize_t i; PyDictKeysObject *k; - assert (PyDict_Check(d)); + assert (PyAnyDict_Check(d)); ASSERT_DICT_LOCKED(d); if (di->di_used != d->ma_used) { @@ -5290,7 +5367,7 @@ dictiter_iternextvalue_lock_held(PyDictObject *d, PyObject *self) PyObject *value; Py_ssize_t i; - assert (PyDict_Check(d)); + assert (PyAnyDict_Check(d)); ASSERT_DICT_LOCKED(d); if (di->di_used != d->ma_used) { @@ -5412,7 +5489,7 @@ dictiter_iternextitem_lock_held(PyDictObject *d, PyObject *self, PyObject *key, *value; Py_ssize_t i; - assert (PyDict_Check(d)); + assert (PyAnyDict_Check(d)); ASSERT_DICT_LOCKED(d); if (di->di_used != d->ma_used) { @@ -5518,7 +5595,7 @@ dictiter_iternext_threadsafe(PyDictObject *d, PyObject *self, Py_ssize_t i; PyDictKeysObject *k; - assert (PyDict_Check(d)); + assert (PyAnyDict_Check(d)); if (di->di_used != _Py_atomic_load_ssize_relaxed(&d->ma_used)) { PyErr_SetString(PyExc_RuntimeError, @@ -5712,7 +5789,7 @@ dictreviter_iter_lock_held(PyDictObject *d, PyObject *self) { dictiterobject *di = (dictiterobject *)self; - assert (PyDict_Check(d)); + assert (PyAnyDict_Check(d)); ASSERT_DICT_LOCKED(d); if (di->di_used != d->ma_used) { @@ -5842,7 +5919,7 @@ static PyObject * dict___reversed___impl(PyDictObject *self) /*[clinic end generated code: output=e674483336d1ed51 input=23210ef3477d8c4d]*/ { - assert (PyDict_Check(self)); + assert (PyAnyDict_Check(self)); return dictiter_new(self, &PyDictRevIterKey_Type); } @@ -5915,7 +5992,7 @@ dictview_len(PyObject *self) _PyDictViewObject *dv = (_PyDictViewObject *)self; Py_ssize_t len = 0; if (dv->dv_dict != NULL) - len = FT_ATOMIC_LOAD_SSIZE_RELAXED(dv->dv_dict->ma_used); + len = GET_USED(dv->dv_dict); return len; } @@ -5927,7 +6004,7 @@ _PyDictView_New(PyObject *dict, PyTypeObject *type) PyErr_BadInternalCall(); return NULL; } - if (!PyDict_Check(dict)) { + if (!PyAnyDict_Check(dict)) { /* XXX Get rid of this restriction later */ PyErr_Format(PyExc_TypeError, "%s() requires a dict argument, not '%s'", @@ -6117,7 +6194,7 @@ dictviews_to_set(PyObject *self) if (PyDictKeys_Check(self)) { // PySet_New() has fast path for the dict object. PyObject *dict = (PyObject *)((_PyDictViewObject *)self)->dv_dict; - if (PyDict_CheckExact(dict)) { + if (PyAnyDict_CheckExact(dict)) { left = dict; } } @@ -6847,6 +6924,11 @@ _PyObject_MaterializeManagedDict(PyObject *obj) int _PyDict_SetItem_LockHeld(PyDictObject *dict, PyObject *name, PyObject *value) { + if (!PyDict_Check(dict)) { + PyErr_BadInternalCall(); + return -1; + } + if (value == NULL) { Py_hash_t hash = _PyObject_HashFast(name); if (hash == -1) { @@ -7169,7 +7251,7 @@ _PyObject_IsInstanceDictEmpty(PyObject *obj) if (dict == NULL) { return 1; } - return FT_ATOMIC_LOAD_SSIZE_RELAXED(((PyDictObject *)dict)->ma_used) == 0; + return GET_USED((PyDictObject *)dict) == 0; } int @@ -7631,7 +7713,7 @@ validate_watcher_id(PyInterpreterState *interp, int watcher_id) int PyDict_Watch(int watcher_id, PyObject* dict) { - if (!PyDict_Check(dict)) { + if (!PyAnyDict_Check(dict)) { PyErr_SetString(PyExc_ValueError, "Cannot watch non-dictionary"); return -1; } @@ -7646,7 +7728,7 @@ PyDict_Watch(int watcher_id, PyObject* dict) int PyDict_Unwatch(int watcher_id, PyObject* dict) { - if (!PyDict_Check(dict)) { + if (!PyAnyDict_Check(dict)) { PyErr_SetString(PyExc_ValueError, "Cannot watch non-dictionary"); return -1; } @@ -7743,3 +7825,147 @@ _PyObject_InlineValuesConsistencyCheck(PyObject *obj) return 0; } #endif + +// --- frozendict implementation --------------------------------------------- + +static PyNumberMethods frozendict_as_number = { + .nb_or = frozendict_or, +}; + +static PyMappingMethods frozendict_as_mapping = { + .mp_length = dict_length, + .mp_subscript = dict_subscript, +}; + +static PyMethodDef frozendict_methods[] = { + DICT___CONTAINS___METHODDEF + {"__getitem__", dict_subscript, METH_O | METH_COEXIST, getitem__doc__}, + DICT___SIZEOF___METHODDEF + DICT_GET_METHODDEF + DICT_KEYS_METHODDEF + DICT_ITEMS_METHODDEF + DICT_VALUES_METHODDEF + DICT_FROMKEYS_METHODDEF + DICT_COPY_METHODDEF + DICT___REVERSED___METHODDEF + {"__class_getitem__", Py_GenericAlias, METH_O|METH_CLASS, PyDoc_STR("See PEP 585")}, + {NULL, NULL} /* sentinel */ +}; + + +static PyObject * +frozendict_repr(PyObject *self) +{ + PyObject *repr = dict_repr(self); + if (repr == NULL) { + return NULL; + } + assert(PyUnicode_Check(repr)); + + PyObject *res = PyUnicode_FromFormat("%s(%U)", + Py_TYPE(self)->tp_name, + repr); + Py_DECREF(repr); + return res; +} + +static Py_hash_t +frozendict_hash(PyObject *op) +{ + PyFrozenDictObject *self = _PyFrozenDictObject_CAST(op); + Py_hash_t hash = FT_ATOMIC_LOAD_SSIZE_RELAXED(self->ma_hash); + if (hash != -1) { + return hash; + } + + PyObject *items = _PyDictView_New(op, &PyDictItems_Type); + if (items == NULL) { + return -1; + } + PyObject *frozenset = PyFrozenSet_New(items); + Py_DECREF(items); + if (frozenset == NULL) { + return -1; + } + + hash = PyObject_Hash(frozenset); + Py_DECREF(frozenset); + if (hash == -1) { + return -1; + } + + FT_ATOMIC_STORE_SSIZE_RELAXED(self->ma_hash, hash); + return hash; +} + + +static PyObject * +frozendict_new(PyTypeObject *type, PyObject *args, PyObject *kwds) +{ + PyObject *d = dict_new(type, args, kwds); + if (d == NULL) { + return NULL; + } + PyFrozenDictObject *self = _PyFrozenDictObject_CAST(d); + self->ma_hash = -1; + + if (args != NULL) { + if (dict_update_common(d, args, kwds, "frozendict") < 0) { + Py_DECREF(d); + return NULL; + } + } + else { + assert(kwds == NULL); + } + + return d; +} + + +PyObject* +PyFrozenDict_New(PyObject *iterable) +{ + if (iterable != NULL) { + PyObject *args = PyTuple_Pack(1, iterable); + if (args == NULL) { + return NULL; + } + PyObject *frozendict = frozendict_new(&PyFrozenDict_Type, args, NULL); + Py_DECREF(args); + return frozendict; + } + else { + PyObject *args = Py_GetConstantBorrowed(Py_CONSTANT_EMPTY_TUPLE); + return frozendict_new(&PyFrozenDict_Type, args, NULL); + } +} + + +PyTypeObject PyFrozenDict_Type = { + PyVarObject_HEAD_INIT(&PyType_Type, 0) + .tp_name = "frozendict", + .tp_basicsize = sizeof(PyFrozenDictObject), + .tp_dealloc = dict_dealloc, + .tp_repr = frozendict_repr, + .tp_as_number = &frozendict_as_number, + .tp_as_sequence = &dict_as_sequence, + .tp_as_mapping = &frozendict_as_mapping, + .tp_hash = frozendict_hash, + .tp_getattro = PyObject_GenericGetAttr, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC + | Py_TPFLAGS_BASETYPE + | _Py_TPFLAGS_MATCH_SELF | Py_TPFLAGS_MAPPING, + .tp_doc = dictionary_doc, + .tp_traverse = dict_traverse, + .tp_clear = dict_tp_clear, + .tp_richcompare = dict_richcompare, + .tp_iter = dict_iter, + .tp_methods = frozendict_methods, + .tp_init = dict_init, + .tp_alloc = _PyType_AllocNoTrack, + .tp_new = frozendict_new, + .tp_free = PyObject_GC_Del, + .tp_vectorcall = dict_vectorcall, + .tp_version_tag = _Py_TYPE_VERSION_FROZENDICT, +}; diff --git a/Objects/object.c b/Objects/object.c index 1ddd949d28143e..ab73d2eb1c9c1f 100644 --- a/Objects/object.c +++ b/Objects/object.c @@ -58,7 +58,7 @@ _PyObject_CheckConsistency(PyObject *op, int check_content) if (PyUnicode_Check(op)) { _PyUnicode_CheckConsistency(op, check_content); } - else if (PyDict_Check(op)) { + else if (PyAnyDict_Check(op)) { _PyDict_CheckConsistency(op, check_content); } return 1; @@ -2532,8 +2532,9 @@ static PyTypeObject* static_types[] = { &PyEnum_Type, &PyFilter_Type, &PyFloat_Type, - &PyFrame_Type, &PyFrameLocalsProxy_Type, + &PyFrame_Type, + &PyFrozenDict_Type, &PyFrozenSet_Type, &PyFunction_Type, &PyGen_Type, diff --git a/Python/bltinmodule.c b/Python/bltinmodule.c index 9144793ae73ce1..493a6e0413d8eb 100644 --- a/Python/bltinmodule.c +++ b/Python/bltinmodule.c @@ -3536,6 +3536,7 @@ _PyBuiltin_Init(PyInterpreterState *interp) SETBUILTIN("enumerate", &PyEnum_Type); SETBUILTIN("filter", &PyFilter_Type); SETBUILTIN("float", &PyFloat_Type); + SETBUILTIN("frozendict", &PyFrozenDict_Type); SETBUILTIN("frozenset", &PyFrozenSet_Type); SETBUILTIN("property", &PyProperty_Type); SETBUILTIN("int", &PyLong_Type); diff --git a/Python/marshal.c b/Python/marshal.c index 190fcdc89afaa8..a71909f103ebfc 100644 --- a/Python/marshal.c +++ b/Python/marshal.c @@ -67,6 +67,7 @@ module marshal #define TYPE_TUPLE '(' // See also TYPE_SMALL_TUPLE. #define TYPE_LIST '[' #define TYPE_DICT '{' +#define TYPE_FROZENDICT '}' #define TYPE_CODE 'c' #define TYPE_UNICODE 'u' #define TYPE_UNKNOWN '?' @@ -575,10 +576,15 @@ w_complex_object(PyObject *v, char flag, WFILE *p) w_object(PyList_GET_ITEM(v, i), p); } } - else if (PyDict_CheckExact(v)) { + else if (PyAnyDict_CheckExact(v)) { Py_ssize_t pos; PyObject *key, *value; - W_TYPE(TYPE_DICT, p); + if (PyFrozenDict_CheckExact(v)) { + W_TYPE(TYPE_FROZENDICT, p); + } + else { + W_TYPE(TYPE_DICT, p); + } /* This one is NULL object terminated! */ pos = 0; while (PyDict_Next(v, &pos, &key, &value)) { @@ -1420,6 +1426,7 @@ r_object(RFILE *p) break; case TYPE_DICT: + case TYPE_FROZENDICT: v = PyDict_New(); R_REF(v); if (v == NULL) @@ -1443,7 +1450,16 @@ r_object(RFILE *p) Py_DECREF(val); } if (PyErr_Occurred()) { - Py_SETREF(v, NULL); + Py_CLEAR(v); + } + if (type == TYPE_FROZENDICT && v != NULL) { + PyObject *frozendict = PyFrozenDict_New(v); + if (frozendict != NULL) { + Py_SETREF(v, frozendict); + } + else { + Py_CLEAR(v); + } } retval = v; break; diff --git a/Tools/c-analyzer/cpython/ignored.tsv b/Tools/c-analyzer/cpython/ignored.tsv index 91bbf94990ecc1..cbec0bf262f0e0 100644 --- a/Tools/c-analyzer/cpython/ignored.tsv +++ b/Tools/c-analyzer/cpython/ignored.tsv @@ -769,6 +769,7 @@ Modules/clinic/md5module.c.h _md5_md5 _keywords - Modules/clinic/grpmodule.c.h grp_getgrgid _keywords - Modules/clinic/grpmodule.c.h grp_getgrnam _keywords - Objects/object.c - constants static PyObject*[] +Objects/dictobject.c - PyFrozenDict_Type - ## False positives From 1fb7c5aaf1dd4b41afc27d04422de9a23fae8ec5 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Thu, 12 Feb 2026 19:25:15 +0100 Subject: [PATCH 02/10] Document PyFrozenDict_Type in the C API doc --- Doc/c-api/dict.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Doc/c-api/dict.rst b/Doc/c-api/dict.rst index 85f4bb8305e977..c9d32e27ba3bf3 100644 --- a/Doc/c-api/dict.rst +++ b/Doc/c-api/dict.rst @@ -496,6 +496,13 @@ Frozen Dictionary Objects .. versionadded:: next +.. c:var:: PyTypeObject PyFrozenDict_Type + + This instance of :c:type:`PyTypeObject` represents the Python frozen + dictionary type. + This is the same object as :class:`frozendict` in the Python layer. + + .. c:function:: int PyAnyDict_Check(PyObject *p) Return true if *p* is a dict object, a frozendict object, or an instance of From fca2e5b567e07b024335f7027355fefb72890f4a Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Thu, 12 Feb 2026 19:37:54 +0100 Subject: [PATCH 03/10] Fix test_inspect and test_doctest --- Lib/test/test_doctest/test_doctest.py | 2 +- Lib/test/test_inspect/test_inspect.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_doctest/test_doctest.py b/Lib/test/test_doctest/test_doctest.py index 241d09db1fa70e..b125693ab0891c 100644 --- a/Lib/test/test_doctest/test_doctest.py +++ b/Lib/test/test_doctest/test_doctest.py @@ -742,7 +742,7 @@ def non_Python_modules(): r""" >>> import builtins >>> tests = doctest.DocTestFinder().find(builtins) - >>> 750 < len(tests) < 800 # approximate number of objects with docstrings + >>> 750 < len(tests) < 850 # approximate number of objects with docstrings True >>> real_tests = [t for t in tests if len(t.examples) > 0] >>> len(real_tests) # objects that actually have doctests diff --git a/Lib/test/test_inspect/test_inspect.py b/Lib/test/test_inspect/test_inspect.py index e4a3a7d9add2c2..4c52142669c273 100644 --- a/Lib/test/test_inspect/test_inspect.py +++ b/Lib/test/test_inspect/test_inspect.py @@ -6127,7 +6127,8 @@ def _test_builtin_methods_have_signatures(self, cls, no_signature, unsupported_s self.assertRaises(ValueError, inspect.signature, getattr(cls, name)) def test_builtins_have_signatures(self): - no_signature = {'type', 'super', 'bytearray', 'bytes', 'dict', 'int', 'str'} + no_signature = {'type', 'super', 'bytearray', 'bytes', + 'dict', 'frozendict', 'int', 'str'} # These need PEP 457 groups needs_groups = {"range", "slice", "dir", "getattr", "next", "iter", "vars"} From 99b291c5c805962967243525729c8598efeaaa86 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Fri, 13 Feb 2026 08:45:10 +0100 Subject: [PATCH 04/10] Complete the documentation --- Doc/c-api/dict.rst | 2 ++ Doc/library/stdtypes.rst | 45 ++++++++++++++++++++++++++++------------ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/Doc/c-api/dict.rst b/Doc/c-api/dict.rst index c9d32e27ba3bf3..bdd2bcdebd4442 100644 --- a/Doc/c-api/dict.rst +++ b/Doc/c-api/dict.rst @@ -536,6 +536,8 @@ Frozen Dictionary Objects Return a new frozendict from an iterable, or ``NULL`` on failure with an exception set. + Create an empty dictionary if *iterable* is ``NULL``. + Ordered Dictionaries ^^^^^^^^^^^^^^^^^^^^ diff --git a/Doc/library/stdtypes.rst b/Doc/library/stdtypes.rst index acc1bd201f82b8..b1b076361b36d1 100644 --- a/Doc/library/stdtypes.rst +++ b/Doc/library/stdtypes.rst @@ -5588,20 +5588,10 @@ can be used interchangeably to index the same dictionary entry. .. versionchanged:: 3.8 Dictionaries are now reversible. -.. class:: frozendict(**kwargs) - frozendict(mapping, /, **kwargs) - frozendict(iterable, /, **kwargs) - - Return a new frozen dictionary initialized from an optional positional - argument and a possibly empty set of keyword arguments. - - .. versionadded:: next - - -.. seealso:: - :class:`types.MappingProxyType` can be used to create a read-only view - of a :class:`dict`. + .. seealso:: + :class:`types.MappingProxyType` can be used to create a read-only view + of a :class:`dict`. .. _thread-safety-dict: @@ -5849,6 +5839,35 @@ An example of dictionary view usage:: 500 +.. class:: frozendict(**kwargs) + frozendict(mapping, /, **kwargs) + frozendict(iterable, /, **kwargs) + + Return a new frozen dictionary initialized from an optional positional + argument and a possibly empty set of keyword arguments. + + A frozendict has a similar API than the :class:`dict` API, with the + following differences: + + * :class:`dict` has more methods than :class:`frozendict`: + + * :meth:`~dict.__delitem__` + * :meth:`~dict.__setitem__` + * :meth:`~dict.clear` + * :meth:`~dict.pop` + * :meth:`~dict.popitem` + * :meth:`~dict.setdefault` + * :meth:`~dict.update` + + * A frozendict can be hashed with ``hash(frozendict)`` if all keys and + values can be hashed. + + * ``frozendict |= other`` does not modify the frozendict in-place but + creates a new frozen dictionary. + + .. versionadded:: next + + .. _typecontextmanager: Context Manager Types From 5e4a284f550241b681b04ed64ed707b97054e6b6 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Fri, 13 Feb 2026 08:53:48 +0100 Subject: [PATCH 05/10] Fix doc warnings Fix also indentation. --- Doc/library/stdtypes.rst | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Doc/library/stdtypes.rst b/Doc/library/stdtypes.rst index b1b076361b36d1..e3e1a06970ad92 100644 --- a/Doc/library/stdtypes.rst +++ b/Doc/library/stdtypes.rst @@ -5851,19 +5851,19 @@ An example of dictionary view usage:: * :class:`dict` has more methods than :class:`frozendict`: - * :meth:`~dict.__delitem__` - * :meth:`~dict.__setitem__` + * :meth:`~!dict.__delitem__` + * :meth:`~!dict.__setitem__` * :meth:`~dict.clear` * :meth:`~dict.pop` * :meth:`~dict.popitem` * :meth:`~dict.setdefault` * :meth:`~dict.update` - * A frozendict can be hashed with ``hash(frozendict)`` if all keys and - values can be hashed. + * A frozendict can be hashed with ``hash(frozendict)`` if all keys and + values can be hashed. - * ``frozendict |= other`` does not modify the frozendict in-place but - creates a new frozen dictionary. + * ``frozendict |= other`` does not modify the frozendict in-place but + creates a new frozen dictionary. .. versionadded:: next From 5c0c6f19c670fb8e69c12861387e170e329c8f42 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Fri, 13 Feb 2026 11:53:37 +0100 Subject: [PATCH 06/10] Apply suggestions from code review Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Doc/library/stdtypes.rst | 16 ++++++++-------- Lib/test/mapping_tests.py | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Doc/library/stdtypes.rst b/Doc/library/stdtypes.rst index e3e1a06970ad92..8c7ef49dc6e22b 100644 --- a/Doc/library/stdtypes.rst +++ b/Doc/library/stdtypes.rst @@ -5305,8 +5305,8 @@ frozenset, a temporary one is created from *elem*. .. _typesmapping: -Mapping Types --- :class:`dict`, :class:`frozendict` -==================================================== +Mapping types --- :class:`!dict`, :class:`!frozendict` +====================================================== .. index:: pair: object; mapping @@ -5846,23 +5846,23 @@ An example of dictionary view usage:: Return a new frozen dictionary initialized from an optional positional argument and a possibly empty set of keyword arguments. - A frozendict has a similar API than the :class:`dict` API, with the + A :class:`!frozendict` has a similar API than the :class:`dict` API, with the following differences: - * :class:`dict` has more methods than :class:`frozendict`: + * :class:`!dict` has more methods than :class:`!frozendict`: - * :meth:`~!dict.__delitem__` - * :meth:`~!dict.__setitem__` + * :meth:`!__delitem__` + * :meth:`!__setitem__` * :meth:`~dict.clear` * :meth:`~dict.pop` * :meth:`~dict.popitem` * :meth:`~dict.setdefault` * :meth:`~dict.update` - * A frozendict can be hashed with ``hash(frozendict)`` if all keys and + * A :class:`!frozendict` can be hashed with ``hash(frozendict)`` if all keys and values can be hashed. - * ``frozendict |= other`` does not modify the frozendict in-place but + * ``frozendict |= other`` does not modify the :class:`!frozendict` in-place but creates a new frozen dictionary. .. versionadded:: next diff --git a/Lib/test/mapping_tests.py b/Lib/test/mapping_tests.py index ccc1268bdff1df..4784ca5aaf479d 100644 --- a/Lib/test/mapping_tests.py +++ b/Lib/test/mapping_tests.py @@ -91,8 +91,8 @@ def test_constructor(self): def test_bool(self): self.assertTrue(not self._empty_mapping()) self.assertTrue(self.reference) - self.assertTrue(bool(self._empty_mapping()) is False) - self.assertTrue(bool(self.reference) is True) + self.assertFalse(bool(self._empty_mapping())) + self.assertTrue(bool(self.reference)) def test_keys(self): d = self._empty_mapping() From 8d4ed141962b5f7689743e4f2261cb906c6d7246 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Fri, 13 Feb 2026 15:46:58 +0100 Subject: [PATCH 07/10] Apply suggestion from @hugovk Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Lib/test/mapping_tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/test/mapping_tests.py b/Lib/test/mapping_tests.py index 4784ca5aaf479d..67b2a553e3f8bd 100644 --- a/Lib/test/mapping_tests.py +++ b/Lib/test/mapping_tests.py @@ -129,10 +129,10 @@ def test_getitem(self): def test_get(self): d = self._empty_mapping() - self.assertTrue(d.get(list(self.other.keys())[0]) is None) + self.assertIsNone(d.get(list(self.other.keys())[0])) self.assertEqual(d.get(list(self.other.keys())[0], 3), 3) d = self.reference - self.assertTrue(d.get(list(self.other.keys())[0]) is None) + self.assertIsNone(d.get(list(self.other.keys())[0])) self.assertEqual(d.get(list(self.other.keys())[0], 3), 3) self.assertEqual(d.get(list(self.inmapping.keys())[0]), list(self.inmapping.values())[0]) From c7706138a3ee75f797fb3606821edefc930c2541 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Fri, 13 Feb 2026 15:48:24 +0100 Subject: [PATCH 08/10] Move the What's New entry Document frozendict after lazy import. --- Doc/whatsnew/3.15.rst | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 3c99339cf0843b..1bbb7b78b1a61f 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -65,10 +65,10 @@ Summary -- Release highlights .. PEP-sized items next. -* :pep:`814`: :ref:`Add frozendict built-in type - ` * :pep:`810`: :ref:`Explicit lazy imports for faster startup times ` +* :pep:`814`: :ref:`Add frozendict built-in type + ` * :pep:`799`: :ref:`A dedicated profiling package for organizing Python profiling tools ` * :pep:`799`: :ref:`Tachyon: High frequency statistical sampling profiler @@ -86,20 +86,6 @@ Summary -- Release highlights New features ============ -.. _whatsnew315-pep814: - -:pep:`814`: Add frozendict built-in type ----------------------------------------- - -A new public immutable type :class:`frozendict` is added to the :mod:`builtins` -module. It is not a ``dict`` subclass but inherits directly from ``object``. - -A ``frozendict`` can be hashed with ``hash(frozendict)`` if all keys and values -can be hashed. - -.. seealso:: :pep:`814` for the full specification and rationale. - - .. _whatsnew315-pep810: :pep:`810`: Explicit lazy imports @@ -196,6 +182,21 @@ raise :exc:`SyntaxError`). (Contributed by Pablo Galindo Salgado and Dino Viehland in :gh:`142349`.) + +.. _whatsnew315-pep814: + +:pep:`814`: Add frozendict built-in type +---------------------------------------- + +A new public immutable type :class:`frozendict` is added to the :mod:`builtins` +module. It is not a ``dict`` subclass but inherits directly from ``object``. + +A ``frozendict`` can be hashed with ``hash(frozendict)`` if all keys and values +can be hashed. + +.. seealso:: :pep:`814` for the full specification and rationale. + + .. _whatsnew315-profiling-package: :pep:`799`: A dedicated profiling package From 6dfec48b0a3c7925decbc75bdf7410e5d2582af6 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Fri, 13 Feb 2026 15:59:10 +0100 Subject: [PATCH 09/10] Revert _PyDict_LoadBuiltinsFromGlobals() change --- Objects/dictobject.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Objects/dictobject.c b/Objects/dictobject.c index c7619b6fd14619..46b0148cf59ab5 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -2638,7 +2638,7 @@ _PyDict_LoadGlobalStackRef(PyDictObject *globals, PyDictObject *builtins, PyObje PyObject * _PyDict_LoadBuiltinsFromGlobals(PyObject *globals) { - if (!PyAnyDict_Check(globals)) { + if (!PyDict_Check(globals)) { PyErr_BadInternalCall(); return NULL; } From 9101b1a432f7976795098071aa7e8bca9623ee5d Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Fri, 13 Feb 2026 16:12:37 +0100 Subject: [PATCH 10/10] Document that frozendict is not a dict subclass --- Doc/library/stdtypes.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Doc/library/stdtypes.rst b/Doc/library/stdtypes.rst index 8c7ef49dc6e22b..0ea24c08cb7c94 100644 --- a/Doc/library/stdtypes.rst +++ b/Doc/library/stdtypes.rst @@ -5865,6 +5865,9 @@ An example of dictionary view usage:: * ``frozendict |= other`` does not modify the :class:`!frozendict` in-place but creates a new frozen dictionary. + :class:`!frozendict` is not a :class:`!dict` subclass but inherits directly + from ``object``. + .. versionadded:: next