From 3959ee4691cbec182ab340fad998b1865c06fff0 Mon Sep 17 00:00:00 2001 From: pranavchoudhary-tech Date: Fri, 14 Aug 2026 16:21:11 +0530 Subject: [PATCH 1/3] gh-155733: validate keyword keys in operator.methodcaller and functools.partial --- Lib/test/test_functools.py | 8 ++++++++ Lib/test/test_operator.py | 5 +++++ Modules/_functoolsmodule.c | 20 +++++++++++++++++--- Modules/_operator.c | 12 ++++++++++++ 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 941dd7249a48d91..e44ce17ad0881bf 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -160,6 +160,14 @@ def test_keyword(self): empty, got = p(x=None) self.assertTrue(expected == got and empty == ()) + def test_non_string_keywords(self): + with self.assertRaisesRegex(TypeError, "keywords must be strings"): + self.partial(capture, **{1: 'x'}) + p = self.partial(capture) + p.keywords[1] = 'x' + with self.assertRaisesRegex(TypeError, "keywords must be strings"): + p() + def test_no_side_effects(self): # make sure there are no side effects that affect subsequent calls p = self.partial(capture, 0, a=1) diff --git a/Lib/test/test_operator.py b/Lib/test/test_operator.py index 1f89986c777ced8..e39a31d372d9bdf 100644 --- a/Lib/test/test_operator.py +++ b/Lib/test/test_operator.py @@ -511,6 +511,11 @@ def return_arguments(self, *args, **kwds): f = operator.methodcaller('return_arguments', *many_positional_arguments, **many_kw_arguments) self.assertEqual(f(a), (many_positional_arguments, many_kw_arguments)) + def test_non_string_keywords(self): + operator = self.module + with self.assertRaisesRegex(TypeError, "keywords must be strings"): + operator.methodcaller('x', **{1: 'x'}) + def test_inplace(self): operator = self.module class C(object): diff --git a/Modules/_functoolsmodule.c b/Modules/_functoolsmodule.c index b4595c55d519b93..5119e94a25bd72e 100644 --- a/Modules/_functoolsmodule.c +++ b/Modules/_functoolsmodule.c @@ -190,11 +190,16 @@ partial_new(PyTypeObject *type, PyObject *args, PyObject *kw) return NULL; } - /* keyword Placeholder prohibition */ + /* keyword Placeholder prohibition and key type validation */ if (kw != NULL) { PyObject *key, *val; Py_ssize_t pos = 0; while (PyDict_Next(kw, &pos, &key, &val)) { + if (!PyUnicode_Check(key)) { + PyErr_SetString(PyExc_TypeError, + "keywords must be strings"); + return NULL; + } if (val == phold) { PyErr_SetString(PyExc_TypeError, "Placeholder cannot be passed as a keyword argument"); @@ -493,18 +498,27 @@ partial_vectorcall(PyObject *self, PyObject *const *args, PyTuple_SET_ITEM(tot_kwnames, pto_nkwds + i, key); } - /* Copy pto_keywords with overlapping call keywords merged - * Note, tail is already coppied. */ Py_ssize_t pos = 0, i = 0; PyObject *keyword_dict = n_merges ? pto_kw_merged : pto->kw; + int valid_keys = 1; Py_BEGIN_CRITICAL_SECTION(keyword_dict); while (PyDict_Next(keyword_dict, &pos, &key, &val)) { + if (!PyUnicode_Check(key)) { + valid_keys = 0; + break; + } assert(i < pto_nkwds); PyTuple_SET_ITEM(tot_kwnames, i, Py_NewRef(key)); stack[tot_nargs + i] = val; i++; } Py_END_CRITICAL_SECTION(); + if (!valid_keys) { + PyErr_SetString(PyExc_TypeError, "keywords must be strings"); + Py_XDECREF(pto_kw_merged); + Py_DECREF(tot_kwnames); + goto error; + } assert(i == pto_nkwds); Py_XDECREF(pto_kw_merged); diff --git a/Modules/_operator.c b/Modules/_operator.c index 417403dc4c10c11..babe93010c3fb89 100644 --- a/Modules/_operator.c +++ b/Modules/_operator.c @@ -1705,6 +1705,18 @@ methodcaller_new(PyTypeObject *type, PyObject *args, PyObject *kwds) return NULL; } + if (kwds != NULL && PyDict_Check(kwds)) { + PyObject *key, *value; + Py_ssize_t pos = 0; + while (PyDict_Next(kwds, &pos, &key, &value)) { + if (!PyUnicode_Check(key)) { + PyErr_SetString(PyExc_TypeError, + "keywords must be strings"); + return NULL; + } + } + } + _operator_state *state = _PyType_GetModuleState(type); /* create methodcallerobject structure */ mc = PyObject_GC_New(methodcallerobject, (PyTypeObject *)state->methodcaller_type); From c1278443469f95a8722a67e95ec75f42d8a6e39e Mon Sep 17 00:00:00 2001 From: pranavchoudhary-tech Date: Fri, 14 Aug 2026 16:27:12 +0530 Subject: [PATCH 2/3] gh-155733: Add news entry for non-string keyword argument validation --- .../next/C_API/2026-08-14-10-54-00.gh-issue-155733.b2A34f.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/C_API/2026-08-14-10-54-00.gh-issue-155733.b2A34f.rst diff --git a/Misc/NEWS.d/next/C_API/2026-08-14-10-54-00.gh-issue-155733.b2A34f.rst b/Misc/NEWS.d/next/C_API/2026-08-14-10-54-00.gh-issue-155733.b2A34f.rst new file mode 100644 index 000000000000000..603a5190e8e1b4c --- /dev/null +++ b/Misc/NEWS.d/next/C_API/2026-08-14-10-54-00.gh-issue-155733.b2A34f.rst @@ -0,0 +1 @@ +Validate keyword argument keys in ``operator.methodcaller`` and ``functools.partial`` to raise ``TypeError`` instead of crashing when non-string keys are passed. From 5003a532fcda110538cb8c63824d2123e2b5fc87 Mon Sep 17 00:00:00 2001 From: pranavchoudhary-tech Date: Sun, 16 Aug 2026 00:01:51 +0530 Subject: [PATCH 3/3] gh-155733: Validate string keys in partial.__setstate__ --- Lib/functools.py | 3 +++ Lib/test/test_functools.py | 3 +++ Modules/_functoolsmodule.c | 10 ++++++++++ 3 files changed, 16 insertions(+) diff --git a/Lib/functools.py b/Lib/functools.py index 8425f6030010f3d..1f9afe1ccb7aad3 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -417,6 +417,9 @@ def __setstate__(self, state): (namespace is not None and not isinstance(namespace, dict))): raise TypeError("invalid partial state") + if kwds is not None and any(not isinstance(k, str) for k in kwds): + raise TypeError("keywords must be strings") + if args and args[-1] is Placeholder: raise TypeError("trailing Placeholders are not allowed") phcount, merger = _partial_prepare_merger(args) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 086dd23abac615c..8512988d1945046 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -167,6 +167,9 @@ def test_non_string_keywords(self): p.keywords[1] = 'x' with self.assertRaisesRegex(TypeError, "keywords must be strings"): p() + p2 = self.partial(capture) + with self.assertRaisesRegex(TypeError, "keywords must be strings"): + p2.__setstate__((capture, (), {1: 'x'}, None)) def test_no_side_effects(self): # make sure there are no side effects that affect subsequent calls diff --git a/Modules/_functoolsmodule.c b/Modules/_functoolsmodule.c index 025388c01886a32..3ba0443d702ad94 100644 --- a/Modules/_functoolsmodule.c +++ b/Modules/_functoolsmodule.c @@ -830,6 +830,16 @@ partial_setstate(PyObject *self, PyObject *state) PyErr_SetString(PyExc_TypeError, "invalid partial state"); return NULL; } + if (kw != Py_None) { + Py_ssize_t pos = 0; + PyObject *key, *val; + while (PyDict_Next(kw, &pos, &key, &val)) { + if (!PyUnicode_Check(key)) { + PyErr_SetString(PyExc_TypeError, "keywords must be strings"); + return NULL; + } + } + } Py_ssize_t nargs = PyTuple_GET_SIZE(fnargs); if (nargs && PyTuple_GET_ITEM(fnargs, nargs - 1) == pto->placeholder) {