https://github.com/python/cpython/commit/f25ee0eeb0b03ef29b21a638819055d84b7f10c1
commit: f25ee0eeb0b03ef29b21a638819055d84b7f10c1
branch: main
author: sobolevn <[email protected]>
committer: encukou <[email protected]>
date: 2026-11-04T14:58:46+01:00
summary:

gh-153410: Add `PyDict_AsFrozenDictAndClear` C-API (GH-153413)


Co-authored-by: Victor Stinner <[email protected]>

files:
A Misc/NEWS.d/next/C_API/2026-07-09-13-52-17.gh-issue-153410.neaDgY.rst
M Doc/c-api/dict.rst
M Doc/whatsnew/3.16.rst
M Include/cpython/dictobject.h
M Lib/test/test_capi/test_dict.py
M Modules/_testcapi/dict.c
M Objects/dictobject.c
M Python/marshal.c

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 ed87669ee4e4a5f..514ab2605e37166 100644
--- a/Doc/whatsnew/3.16.rst
+++ b/Doc/whatsnew/3.16.rst
@@ -1380,6 +1380,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 792602184b44f81..88a8d61828503f7 100644
--- a/Objects/dictobject.c
+++ b/Objects/dictobject.c
@@ -3203,6 +3203,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)
 {
@@ -3222,10 +3233,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);
@@ -8645,6 +8653,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 d4a63361148b434..ca0468aa6765a41 100644
--- a/Python/marshal.c
+++ b/Python/marshal.c
@@ -1951,7 +1951,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;

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to