diff --git a/Doc/library/functools.rst b/Doc/library/functools.rst index 265610db3caabd..70166891f4cd07 100644 --- a/Doc/library/functools.rst +++ b/Doc/library/functools.rst @@ -411,6 +411,9 @@ The :mod:`!functools` module defines the following functions: .. versionchanged:: 3.14 Added support for :data:`Placeholder` in positional arguments. + .. versionchanged:: 3.15 + :class:`partial` now stores keywords in a :class:`frozendict` + .. class:: partialmethod(func, /, *args, **keywords) Return a new :class:`partialmethod` descriptor which behaves diff --git a/Lib/functools.py b/Lib/functools.py index cd374631f16792..bfaa82e88be4bc 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -342,11 +342,12 @@ def _partial_new(cls, func, /, *args, **keywords): phcount, merger = _partial_prepare_merger(tot_args) else: # works for both pto_phcount == 0 and != 0 phcount, merger = pto_phcount, func._merger - keywords = {**func.keywords, **keywords} + keywords = frozendict(**func.keywords, **keywords) func = func.func else: tot_args = args phcount, merger = _partial_prepare_merger(tot_args) + keywords = frozendict(**keywords) self = object.__new__(cls) self.func = func @@ -397,6 +398,12 @@ def __get__(self, obj, objtype=None): return self return MethodType(self, obj) + def __reduce_ex__(self, protocol): + if protocol >= 2: + return self.__reduce__() + return type(self), (self.func,), (self.func, self.args, + dict(self.keywords) or None, self.__dict__ or None) + def __reduce__(self): return type(self), (self.func,), (self.func, self.args, self.keywords or None, self.__dict__ or None) @@ -408,9 +415,11 @@ def __setstate__(self, state): raise TypeError(f"expected 4 items in state, got {len(state)}") func, args, kwds, namespace = state if (not callable(func) or not isinstance(args, tuple) or - (kwds is not None and not isinstance(kwds, dict)) or (namespace is not None and not isinstance(namespace, dict))): raise TypeError("invalid partial state") + if kwds is not None and not ( + isinstance(kwds, dict) or isinstance(kwds, frozendict)): + raise TypeError(f"keywords must be an instance of dict or frozendict, not {type(kwds)}") if args and args[-1] is Placeholder: raise TypeError("trailing Placeholders are not allowed") @@ -418,9 +427,13 @@ def __setstate__(self, state): args = tuple(args) # just in case it's a subclass if kwds is None: - kwds = {} - elif type(kwds) is not dict: # XXX does it need to be *exactly* dict? - kwds = dict(kwds) + kwds = frozendict() + else: + for key in kwds: + if type(key) is not str: + raise TypeError("keywords must be a string") + kwds = frozendict(kwds) + if namespace is None: namespace = {} diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index dda42cb33072c3..e48ba678fa0490 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -65,6 +65,9 @@ def __add__(self, other): class MyDict(dict): pass +class MyFrozenDict(frozendict): + pass + class TestImportTime(unittest.TestCase): @cpython_only @@ -404,6 +407,16 @@ def test_setstate(self): with self.assertRaisesRegex(TypeError, f'^{msg_regex}$') as cm: f.__setstate__((capture, (1, PH), dict(a=10), dict(attr=[]))) + with self.assertRaises(TypeError): + f.__setstate__((capture, (1,), {1234: 1234}, dict(attr=[]))) + + class FakeString(str): + pass + + with self.assertRaises(TypeError): + f.__setstate__((capture, (1,), {FakeString("string"): 1234}, dict(attr=[]))) + + def test_setstate_errors(self): f = self.partial(signature) @@ -423,7 +436,18 @@ def test_setstate_subclasses(self): s = signature(f) self.assertEqual(s, (capture, (1,), dict(a=10), {})) self.assertIs(type(s[1]), tuple) - self.assertIs(type(s[2]), dict) + self.assertIs(type(s[2]), frozendict) + r = f() + self.assertEqual(r, ((1,), {'a': 10})) + self.assertIs(type(r[0]), tuple) + self.assertIs(type(r[1]), dict) + + + f.__setstate__((capture, MyTuple((1,)), MyFrozenDict(a=10), None)) + s = signature(f) + self.assertEqual(s, (capture, (1,), dict(a=10), {})) + self.assertIs(type(s[1]), tuple) + self.assertIs(type(s[2]), frozendict) r = f() self.assertEqual(r, ((1,), {'a': 10})) self.assertIs(type(r[0]), tuple) @@ -588,30 +612,15 @@ def test_attributes_unwritable(self): else: self.fail('partial object allowed __dict__ to be deleted') - def test_manually_adding_non_string_keyword(self): + def test_keyword_mutations(self): p = self.partial(capture) - # Adding a non-string/unicode keyword to partial kwargs - p.keywords[1234] = 'value' - r = repr(p) - self.assertIn('1234', r) - self.assertIn("'value'", r) - with self.assertRaises(TypeError): - p() - def test_keystr_replaces_value(self): - p = self.partial(capture) + with self.assertRaises(TypeError): + p.keywords["new key"] = ['sth'] - class MutatesYourDict(object): - def __str__(self): - p.keywords[self] = ['sth2'] - return 'astr' - - # Replacing the value during key formatting should keep the original - # value alive (at least long enough). - p.keywords[MutatesYourDict()] = ['sth'] - r = repr(p) - self.assertIn('astr', r) - self.assertIn("['sth']", r) + # Adding a non-string/unicode keyword to partial kwargs + with self.assertRaises(TypeError): + p.keywords[1234] = 'value' def test_placeholders_refcount_smoke(self): PH = self.module.Placeholder diff --git a/Misc/NEWS.d/next/Library/2026-03-15-03-40-12.gh-issue-145478.akeScO.rst b/Misc/NEWS.d/next/Library/2026-03-15-03-40-12.gh-issue-145478.akeScO.rst new file mode 100644 index 00000000000000..73c8f1ef6e4566 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-03-15-03-40-12.gh-issue-145478.akeScO.rst @@ -0,0 +1 @@ +Now :func:`functools.partial` stores keywords in a :class:`frozendict` and enforces that they are always strings. diff --git a/Modules/_functoolsmodule.c b/Modules/_functoolsmodule.c index 723080ede1d9ae..26974785dbc9a8 100644 --- a/Modules/_functoolsmodule.c +++ b/Modules/_functoolsmodule.c @@ -130,7 +130,7 @@ typedef struct { PyObject_HEAD PyObject *fn; PyObject *args; - PyObject *kw; + PyObject *kw; /* frozendict */ PyObject *dict; /* __dict__ */ PyObject *weakreflist; /* List of weak references */ PyObject *placeholder; /* Placeholder for positional arguments */ @@ -195,6 +195,7 @@ partial_new(PyTypeObject *type, PyObject *args, PyObject *kw) PyObject *key, *val; Py_ssize_t pos = 0; while (PyDict_Next(kw, &pos, &key, &val)) { + assert(PyUnicode_CheckExact(key)); if (val == phold) { PyErr_SetString(PyExc_TypeError, "Placeholder cannot be passed as a keyword argument"); @@ -218,14 +219,15 @@ partial_new(PyTypeObject *type, PyObject *args, PyObject *kw) func = part->fn; pto_phcount = part->phcount; assert(PyTuple_Check(pto_args)); - assert(PyDict_Check(pto_kw)); + assert(PyFrozenDict_Check(pto_kw)); } } /* create partialobject structure */ pto = (partialobject *)type->tp_alloc(type, 0); - if (pto == NULL) + if (pto == NULL) { return NULL; + } pto->fn = Py_NewRef(func); pto->placeholder = phold; @@ -293,25 +295,30 @@ partial_new(PyTypeObject *type, PyObject *args, PyObject *kw) } if (pto_kw == NULL || PyDict_GET_SIZE(pto_kw) == 0) { + pto->kw = PyFrozenDict_New(kw); + } + else { if (kw == NULL) { - pto->kw = PyDict_New(); - } - else if (_PyObject_IsUniquelyReferenced(kw)) { - pto->kw = Py_NewRef(kw); + pto->kw = Py_NewRef(pto_kw); } else { - pto->kw = PyDict_Copy(kw); - } - } - else { - pto->kw = PyDict_Copy(pto_kw); - if (kw != NULL && pto->kw != NULL) { - if (PyDict_Merge(pto->kw, kw, 1) != 0) { + PyObject *tmp_dict = PyObject_CallOneArg((PyObject *)&PyDict_Type, pto_kw); + if (tmp_dict == NULL) { + Py_DECREF(pto); + return NULL; + } + + if (PyDict_Merge(tmp_dict, kw, 1) != 0) { + Py_DECREF(tmp_dict); Py_DECREF(pto); return NULL; } + + pto->kw = PyFrozenDict_New(tmp_dict); + Py_DECREF(tmp_dict); } } + if (pto->kw == NULL) { Py_DECREF(pto); return NULL; @@ -459,7 +466,7 @@ partial_vectorcall(PyObject *self, PyObject *const *args, val = args[nargs + i]; if (PyDict_Contains(pto->kw, key)) { if (pto_kw_merged == NULL) { - pto_kw_merged = PyDict_Copy(pto->kw); + pto_kw_merged = PyObject_CallOneArg((PyObject *)&PyDict_Type, pto->kw); if (pto_kw_merged == NULL) { goto error; } @@ -493,14 +500,12 @@ partial_vectorcall(PyObject *self, PyObject *const *args, * Note, tail is already coppied. */ Py_ssize_t pos = 0, i = 0; PyObject *keyword_dict = n_merges ? pto_kw_merged : pto->kw; - Py_BEGIN_CRITICAL_SECTION(keyword_dict); while (PyDict_Next(keyword_dict, &pos, &key, &val)) { assert(i < pto_nkwds); PyTuple_SET_ITEM(tot_kwnames, i, Py_NewRef(key)); stack[tot_nargs + i] = val; i++; } - Py_END_CRITICAL_SECTION(); assert(i == pto_nkwds); Py_XDECREF(pto_kw_merged); @@ -584,7 +589,7 @@ partial_call(PyObject *self, PyObject *args, PyObject *kwargs) partialobject *pto = partialobject_CAST(self); assert(PyCallable_Check(pto->fn)); assert(PyTuple_Check(pto->args)); - assert(PyDict_Check(pto->kw)); + assert(PyFrozenDict_Check(pto->kw)); Py_ssize_t nargs = PyTuple_GET_SIZE(args); Py_ssize_t pto_phcount = pto->phcount; @@ -605,7 +610,7 @@ partial_call(PyObject *self, PyObject *args, PyObject *kwargs) /* bpo-27840, bpo-29318: dictionary of keyword parameters must be copied, because a function using "**kwargs" can modify the dictionary. */ - tot_kw = PyDict_Copy(pto->kw); + tot_kw = PyObject_CallOneArg((PyObject*)&PyDict_Type, pto->kw); if (tot_kw == NULL) { return NULL; } @@ -715,7 +720,7 @@ partial_repr(PyObject *self) PyObject *args = Py_NewRef(pto->args); PyObject *kw = Py_NewRef(pto->kw); assert(PyTuple_Check(args)); - assert(PyDict_Check(kw)); + assert(PyFrozenDict_Check(kw)); arglist = Py_GetConstant(Py_CONSTANT_EMPTY_STR); if (arglist == NULL) { @@ -732,19 +737,15 @@ partial_repr(PyObject *self) } /* Pack keyword arguments */ int error = 0; - Py_BEGIN_CRITICAL_SECTION(kw); for (i = 0; PyDict_Next(kw, &i, &key, &value);) { - /* Prevent key.__str__ from deleting the value. */ - Py_INCREF(value); + assert(PyUnicode_CheckExact(key)); Py_SETREF(arglist, PyUnicode_FromFormat("%U, %S=%R", arglist, key, value)); - Py_DECREF(value); if (arglist == NULL) { error = 1; break; } } - Py_END_CRITICAL_SECTION(); if (error) { goto done; } @@ -782,9 +783,12 @@ static PyObject * partial_reduce(PyObject *self, PyObject *Py_UNUSED(args)) { partialobject *pto = partialobject_CAST(self); - return Py_BuildValue("O(O)(OOOO)", Py_TYPE(pto), pto->fn, pto->fn, - pto->args, pto->kw, + PyObject *keywords_dict = PyObject_CallOneArg((PyObject*)&PyDict_Type, pto->kw); + PyObject *result = Py_BuildValue("O(O)(OOOO)", Py_TYPE(pto), pto->fn, pto->fn, + pto->args, keywords_dict, pto->dict ? pto->dict : Py_None); + Py_DECREF(keywords_dict); + return result; } static PyObject * @@ -800,13 +804,20 @@ partial_setstate(PyObject *self, PyObject *state) if (!PyArg_ParseTuple(state, "OOOO", &fn, &fnargs, &kw, &dict) || !PyCallable_Check(fn) || !PyTuple_Check(fnargs) || - (kw != Py_None && !PyDict_Check(kw)) || (dict != Py_None && !PyDict_Check(dict))) { PyErr_SetString(PyExc_TypeError, "invalid partial state"); return NULL; } + if (kw != Py_None && !PyAnyDict_Check(kw)) + { + PyErr_Format(PyExc_TypeError, + "keywords must be an instance of dict or frozendict, not %.200s", + _PyType_Name(Py_TYPE(kw))); + return NULL; + } + Py_ssize_t nargs = PyTuple_GET_SIZE(fnargs); if (nargs && PyTuple_GET_ITEM(fnargs, nargs - 1) == pto->placeholder) { PyErr_SetString(PyExc_TypeError, @@ -821,28 +832,44 @@ partial_setstate(PyObject *self, PyObject *state) } } - if(!PyTuple_CheckExact(fnargs)) + if(!PyTuple_CheckExact(fnargs)) { fnargs = PySequence_Tuple(fnargs); - else + } + else { Py_INCREF(fnargs); - if (fnargs == NULL) + } + if (fnargs == NULL) { return NULL; + } + + if (kw == Py_None) { + kw = PyFrozenDict_New(NULL); + } + else { + PyObject *key, *val; + for (Py_ssize_t pos = 0; PyDict_Next(kw, &pos, &key, &val);) { + if (!PyUnicode_CheckExact(key)) { + PyErr_SetString(PyExc_TypeError, + "keywords must be a string"); + Py_DECREF(fnargs); + return NULL; + } + } + kw = PyFrozenDict_New(kw); + } - if (kw == Py_None) - kw = PyDict_New(); - else if(!PyDict_CheckExact(kw)) - kw = PyDict_Copy(kw); - else - Py_INCREF(kw); if (kw == NULL) { Py_DECREF(fnargs); return NULL; } - if (dict == Py_None) + if (dict == Py_None) { dict = NULL; - else + } + else { Py_INCREF(dict); + } + Py_SETREF(pto->fn, Py_NewRef(fn)); Py_SETREF(pto->args, fnargs); Py_SETREF(pto->kw, kw); @@ -852,9 +879,23 @@ partial_setstate(PyObject *self, PyObject *state) Py_RETURN_NONE; } +static PyObject * +partial_copy(PyObject *self, PyObject *Py_UNUSED(args)) +{ + partialobject *pto = partialobject_CAST(self); + PyTypeObject *type = Py_TYPE(pto); + partialobject *new_pto = (partialobject *)type->tp_alloc(type, 0); + new_pto->fn = Py_NewRef(pto->fn); + new_pto->args = Py_NewRef(pto->args); + new_pto->kw = Py_NewRef(pto->kw); + new_pto->dict = Py_XNewRef(pto->dict); + return (PyObject *)new_pto; +} + static PyMethodDef partial_methods[] = { {"__reduce__", partial_reduce, METH_NOARGS}, {"__setstate__", partial_setstate, METH_O}, + {"__copy__", partial_copy, METH_NOARGS}, {"__class_getitem__", Py_GenericAlias, METH_O|METH_CLASS, PyDoc_STR("See PEP 585")}, {NULL, NULL} /* sentinel */