diff --git a/Doc/c-api/dict.rst b/Doc/c-api/dict.rst index 87d09ad2412e405..6389cce8ff211f0 100644 --- a/Doc/c-api/dict.rst +++ b/Doc/c-api/dict.rst @@ -507,6 +507,23 @@ 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. + + .. 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 e871ad822bbcedc..11a7ca2666ca1bb 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -668,7 +668,9 @@ C API changes New features ------------ -* TODO +* Add a new way to efficitly create :class:`frozendict` instance + from existing :class:`dict` instances: :c:func:`PyDict_AsFrozenDictAndClear`. + Porting to Python 3.16 ---------------------- diff --git a/Include/cpython/dictobject.h b/Include/cpython/dictobject.h index 5e7811416aba63f..e6cdd04adb06a96 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 `PyDictObject`. +// 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..a51f0c077995595 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 = {1: 2, 'a': 'b'} + f = as_frozendict(d) + self.assertIs(type(f), frozendict) + self.assertEqual(f, frozendict({1: 2, 'a': 'b'})) + 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': None}) + f = as_frozendict(d) + self.assertIs(type(f), frozendict) + self.assertEqual(f, frozendict({'x': None})) + 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 246ffe6c18e9b51..767d6c7ffb14c85 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -3120,6 +3120,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) { @@ -3139,10 +3150,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); @@ -8521,6 +8529,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 9688d426419c2fa..bb69858e9ac8475 100644 --- a/Python/marshal.c +++ b/Python/marshal.c @@ -1501,7 +1501,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)); } retval = v; break;