diff --git a/Doc/c-api/dict.rst b/Doc/c-api/dict.rst index 4ac4445e25886c7..f375af2925b5fd8 100644 --- a/Doc/c-api/dict.rst +++ b/Doc/c-api/dict.rst @@ -507,6 +507,29 @@ Dictionary objects modified by another thread. +.. c:function:: PyObject* PyDict_AsFrozenDictAndClear(PyObject *dict) + + Return a new :class:`frozendict` created + from the existing :class:`dict` instance. + + Transfers all keys and values from *dict* + to the newly allocated :class:`!frozendict`. + Clears the input *dict* on success. + Returns ``NULL`` with the exception set on error. + + This function is intended for building a :class:`!frozendict` instance via + a temporary *dict* that is not exposed to user code. + To "freeze" an arbitrary dictionary, for example one received from the user + as an argument, prefer :c:func:`PyFrozenDict_New` to leave the source + intact. + + .. impl-detail:: + + Works with O(1) complexity. + + .. versionadded:: next + + .. c:function:: int PyDict_AddWatcher(PyDict_WatchCallback callback) Register *callback* as a dictionary watcher. Return a non-negative integer diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index d7fbc9cf1a1de4a..1c0d8e9c531a7cd 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -1377,6 +1377,10 @@ C API changes New features ------------ +* Add a new way to efficiently create a :class:`frozendict` instance by building + a :class:`dict` and "freezing" it: :c:func:`PyDict_AsFrozenDictAndClear`. + (Contributed by Nikita Sobolev in :gh:`153413`.) + * Add :c:func:`Py_HashBuffer` to the limited C API. (Contributed by Victor Stinner in :gh:`148233`.) diff --git a/Include/cpython/dictobject.h b/Include/cpython/dictobject.h index 2b139a1e7db1818..9f05890852f5e4c 100644 --- a/Include/cpython/dictobject.h +++ b/Include/cpython/dictobject.h @@ -106,3 +106,7 @@ 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); + +// Create a frozendict from existing dict. +// Transfers existing keys and values. +PyAPI_FUNC(PyObject*) PyDict_AsFrozenDictAndClear(PyObject *dict); diff --git a/Lib/test/test_capi/test_dict.py b/Lib/test/test_capi/test_dict.py index 4cf404e9f563272..9a2e79d4da7e715 100644 --- a/Lib/test/test_capi/test_dict.py +++ b/Lib/test/test_capi/test_dict.py @@ -609,6 +609,37 @@ def test_dict_popstring(self): # CRASHES dict_popstring({}, NULL) # CRASHES dict_popstring({"a": 1}, NULL) + def test_dict_as_frozendict_and_clear(self): + # Test PyDict_AsFrozenDictAndClear() + as_frozendict = _testcapi.dict_as_frozendict_and_clear + d = dict(x=1, y=2) + f = as_frozendict(d) + self.assertIs(type(f), frozendict) + self.assertEqual(f, frozendict(x=1, y=2)) + self.assertIs(type(d), dict) + self.assertEqual(d, {}) + + d = {} + f = as_frozendict(d) + self.assertIs(type(f), frozendict) + self.assertEqual(f, frozendict()) + self.assertIs(type(d), dict) + self.assertEqual(d, {}) + + class DictSubtype(dict): ... + + d = DictSubtype(x=123) + f = as_frozendict(d) + self.assertIs(type(f), frozendict) + self.assertEqual(f, frozendict(x=123)) + self.assertIs(type(d), DictSubtype) + self.assertEqual(d, DictSubtype({})) + + for wrong_input in (frozendict(), [], None): + with self.subTest(wrong_input=wrong_input): + with self.assertRaises(SystemError): + as_frozendict(wrong_input) + def test_frozendict_check(self): # Test PyFrozenDict_Check() check = _testcapi.frozendict_check diff --git a/Misc/NEWS.d/next/C_API/2026-07-09-13-52-17.gh-issue-153410.neaDgY.rst b/Misc/NEWS.d/next/C_API/2026-07-09-13-52-17.gh-issue-153410.neaDgY.rst new file mode 100644 index 000000000000000..259981100b5bf15 --- /dev/null +++ b/Misc/NEWS.d/next/C_API/2026-07-09-13-52-17.gh-issue-153410.neaDgY.rst @@ -0,0 +1,3 @@ +Add :c:func:`PyDict_AsFrozenDictAndClear` C-API function to create +:class:`frozendict` from existing :class:`dict` object and clear the input +dict instance. diff --git a/Modules/_testcapi/dict.c b/Modules/_testcapi/dict.c index 172591b03182abf..5e169d99c179d57 100644 --- a/Modules/_testcapi/dict.c +++ b/Modules/_testcapi/dict.c @@ -257,6 +257,13 @@ test_dict_iteration(PyObject* self, PyObject *Py_UNUSED(ignored)) Py_RETURN_NONE; } +static PyObject * +dict_as_frozendict_and_clear(PyObject *self, PyObject *obj) +{ + NULLABLE(obj); + return PyDict_AsFrozenDictAndClear(obj); +} + static PyObject * frozendict_check(PyObject *self, PyObject *obj) @@ -306,6 +313,7 @@ static PyMethodDef test_methods[] = { {"dict_popstring", dict_popstring, METH_VARARGS}, {"dict_popstring_null", dict_popstring_null, METH_VARARGS}, {"test_dict_iteration", test_dict_iteration, METH_NOARGS}, + {"dict_as_frozendict_and_clear", dict_as_frozendict_and_clear, METH_O}, {"frozendict_check", frozendict_check, METH_O}, {"frozendict_checkexact", frozendict_checkexact, METH_O}, {"anydict_check", anydict_check, METH_O}, diff --git a/Objects/dictobject.c b/Objects/dictobject.c index fa6fd6b27ef8794..a0151c404f940ea 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -3128,6 +3128,17 @@ clear_embedded_values(PyDictValues *values, Py_ssize_t nentries) } } +// This function is used in both `PyDict_Clear` +// and `PyDict_AsFrozenDictAndClear`, place all the common parts here. +static void +clear_common(PyDictObject *mp) +{ + _PyDict_NotifyEvent(PyDict_EVENT_CLEARED, mp, NULL, NULL); + // We don't inc ref empty keys because they're immortal + ensure_shared_on_resize(mp); + STORE_USED(mp, 0); +} + static void clear_lock_held(PyObject *op) { @@ -3147,10 +3158,7 @@ clear_lock_held(PyObject *op) return; } /* Empty the dict... */ - _PyDict_NotifyEvent(PyDict_EVENT_CLEARED, mp, NULL, NULL); - // We don't inc ref empty keys because they're immortal - ensure_shared_on_resize(mp); - STORE_USED(mp, 0); + clear_common(mp); if (oldvalues == NULL) { set_keys(mp, Py_EMPTY_KEYS); assert(oldkeys->dk_refcnt == 1); @@ -8570,6 +8578,57 @@ frozendict_new(PyTypeObject *type, PyObject *args, PyObject *kwds) return d; } +static void +transfer_keys_and_values_lock_held(PyObject *res, PyObject *dict) +{ + PyDictObject *new = (PyDictObject *)res; + PyDictObject *old = (PyDictObject *)dict; + assert(can_modify_dict(old)); + + // Fast path: do nothing on an empty dict: + if (old->ma_keys == Py_EMPTY_KEYS) { + return; + } + + Py_ssize_t used = old->ma_used; + PyDictKeysObject *keys = old->ma_keys; + PyDictValues *values = old->ma_values; + + // Clear the old dict keys and values, but do not decref them: + clear_common(old); + set_keys(old, Py_EMPTY_KEYS); + set_values(old, NULL); + ASSERT_CONSISTENT(old); + + // Transfer keys and values from dict to frozendict: + new->ma_used = used; + new->ma_keys = keys; + new->ma_values = values; + ASSERT_CONSISTENT(new); +} + +PyObject * +PyDict_AsFrozenDictAndClear(PyObject *dict) +{ + if (dict == NULL || !PyDict_Check(dict)) { + PyErr_BadInternalCall(); + return NULL; + } + + PyObject *res = frozendict_new_untracked(&PyFrozenDict_Type); + if (res == NULL) { + return NULL; + } + + Py_BEGIN_CRITICAL_SECTION(dict); + transfer_keys_and_values_lock_held(res, dict); + Py_END_CRITICAL_SECTION(); + + _PyObject_GC_TRACK(res); + assert(_PyFrozenDictObject_CAST(res)->ma_hash == -1); + return res; +} + PyObject* PyFrozenDict_New(PyObject *iterable) diff --git a/Python/marshal.c b/Python/marshal.c index 2f3112e16443306..843860efaaaaa97 100644 --- a/Python/marshal.c +++ b/Python/marshal.c @@ -1950,7 +1950,7 @@ r_object(RFILE *p) Py_CLEAR(v); } if (type == TYPE_FROZENDICT && v != NULL) { - Py_SETREF(v, PyFrozenDict_New(v)); + Py_SETREF(v, PyDict_AsFrozenDictAndClear(v)); v = r_ref_insert(v, idx, flag, p); } retval = v;