diff --git a/Doc/c-api/tuple.rst b/Doc/c-api/tuple.rst index e8be4762dc33a12..55caa9fc03acd87 100644 --- a/Doc/c-api/tuple.rst +++ b/Doc/c-api/tuple.rst @@ -144,6 +144,11 @@ Tuple Objects ``*p`` is destroyed. On failure, returns ``-1`` and sets ``*p`` to ``NULL``, and raises :exc:`MemoryError` or :exc:`SystemError`. + While tuple objects are usually immutable in Python, this special C API + allows mutating a tuple object in-place. The returned tuple object can still + be mutated using :c:func:`PyTuple_SetItem`; except if *newsize* is zero in + which case it returns the immutable empty tuple. + .. _struct-sequence-objects: diff --git a/Lib/test/test_capi/test_tuple.py b/Lib/test/test_capi/test_tuple.py index fe5d694ea7d831d..c6fead6c254f0dc 100644 --- a/Lib/test/test_capi/test_tuple.py +++ b/Lib/test/test_capi/test_tuple.py @@ -1,6 +1,6 @@ -import unittest import gc -from sys import getrefcount +import sys +import unittest from test.support import import_helper _testcapi = import_helper.import_module('_testcapi') @@ -131,9 +131,9 @@ def check_tuple_from_pair(self, from_pair): class Temp: pass temp = Temp() - temp_rc = getrefcount(temp) + temp_rc = sys.getrefcount(temp) self.assertEqual(from_pair(temp, temp), (temp, temp)) - self.assertEqual(getrefcount(temp), temp_rc) + self.assertEqual(sys.getrefcount(temp), temp_rc) self._not_tracked(from_pair(1, 2)) self._not_tracked(from_pair(None, None)) @@ -299,29 +299,64 @@ def test_tuple_set_item(self): def test__tuple_resize(self): # Test _PyTuple_Resize() - resize = _testcapi._tuple_resize + _resize = _testcapi._tuple_resize checknull = _testcapi._check_tuple_item_is_NULL - a = () - b = resize(a, 0, False) - self.assertEqual(len(a), 0) - self.assertEqual(len(b), 0) - b = resize(a, 2, False) - self.assertEqual(len(a), 0) - self.assertEqual(len(b), 2) - self.assertTrue(checknull(b, 0)) - self.assertTrue(checknull(b, 1)) - - a = ([1], [2], [3]) - b = resize(a, 3) - self.assertEqual(b, a) - b = resize(a, 2) - self.assertEqual(b, a[:2]) - b = resize(a, 5) - self.assertEqual(len(b), 5) - self.assertEqual(b[:3], a) - self.assertTrue(checknull(b, 3)) - self.assertTrue(checknull(b, 4)) + def assert_is_fresh_copy(result, refcnt, is_new_obj): + self.assertEqual(refcnt, 1) + self.assertTrue(is_new_obj) + self.assertFalse(sys._is_immortal(result)) + + def resize(tup, new_size, new=True, compute_hash=False): + if isinstance(tup, tuple): + old_size = len(tup) + else: + old_size = 0 + result, refcnt, is_new_obj = _resize(tup, new_size, new, + compute_hash=compute_hash) + if new_size == old_size: + # Return the same object unchanged + self.assertFalse(is_new_obj) + elif old_size == 0: + assert_is_fresh_copy(result, refcnt, is_new_obj) + elif new_size == 0: + # check that the result is the empty bytes string singleton + self.assertEqual(result, ()) + self.assertTrue(sys._is_immortal(result)) + self.assertTrue(is_new_obj) + elif (not new) or compute_hash: + # Make sure that the result is a fresh object. + assert_is_fresh_copy(result, refcnt, is_new_obj) + else: + # An in-place resize can return the same memory address, or + # not. 'is_new_obj' cannot be tested. + self.assertEqual(refcnt, 1) + self.assertFalse(sys._is_immortal(result)) + + return result + + for compute_hash in True, False: + with self.subTest(compute_hash=compute_hash): + a = () + b = resize(a, 0, False, compute_hash) + self.assertEqual(len(a), 0) + self.assertEqual(len(b), 0) + b = resize(a, 2, False, compute_hash) + self.assertEqual(len(a), 0) + self.assertEqual(len(b), 2) + self.assertTrue(checknull(b, 0)) + self.assertTrue(checknull(b, 1)) + + a = tuple(object() for _ in range(3)) + b = resize(a, 3, True, compute_hash) + self.assertEqual(b, a) + b = resize(a, 2, True, compute_hash) + self.assertEqual(b, a[:2]) + b = resize(a, 5, True, compute_hash) + self.assertEqual(len(b), 5) + self.assertEqual(b[:3], a) + self.assertTrue(checknull(b, 3)) + self.assertTrue(checknull(b, 4)) a = () self.assertRaises(MemoryError, resize, a, PY_SSIZE_T_MAX) diff --git a/Misc/NEWS.d/next/C_API/2026-11-03-01-24-01.gh-issue-161226.MPrJKp.rst b/Misc/NEWS.d/next/C_API/2026-11-03-01-24-01.gh-issue-161226.MPrJKp.rst new file mode 100644 index 000000000000000..7ceeab90b0a0065 --- /dev/null +++ b/Misc/NEWS.d/next/C_API/2026-11-03-01-24-01.gh-issue-161226.MPrJKp.rst @@ -0,0 +1,2 @@ +:c:func:`_PyTuple_Resize` now returns a copy if the hash value was already +computed. Patch by Victor Stinner. diff --git a/Modules/_testcapi/tuple.c b/Modules/_testcapi/tuple.c index 5de1c494c0a8c00..c3a1218d7916e9e 100644 --- a/Modules/_testcapi/tuple.c +++ b/Modules/_testcapi/tuple.c @@ -66,12 +66,17 @@ tuple_set_item(PyObject *Py_UNUSED(module), PyObject *args) } static PyObject * -_tuple_resize(PyObject *Py_UNUSED(module), PyObject *args) +_tuple_resize(PyObject *Py_UNUSED(module), PyObject *args, PyObject *kwargs) { + static char *kwlist[] = {"obj", "newsize", "new", "compute_hash", NULL}; PyObject *tup; Py_ssize_t newsize; int new = 1; - if (!PyArg_ParseTuple(args, "On|p", &tup, &newsize, &new)) { + int compute_hash = 0; + + if (!PyArg_ParseTupleAndKeywords(args, kwargs, + "On|pi", kwlist, + &tup, &newsize, &new, &compute_hash)) { return NULL; } if (new) { @@ -84,12 +89,24 @@ _tuple_resize(PyObject *Py_UNUSED(module), PyObject *args) NULLABLE(tup); Py_XINCREF(tup); } + + if (compute_hash) { + if (PyObject_Hash(tup) == -1) { + Py_DECREF(tup); + return NULL; + } + } + + PyObject *old_tup = tup; int r = _PyTuple_Resize(&tup, newsize); if (r == -1) { assert(tup == NULL); return NULL; } - return tup; + assert(tup != NULL); + + Py_ssize_t refcnt = Py_REFCNT(tup); + return Py_BuildValue("Nnp", tup, refcnt, tup != old_tup); } static PyObject * @@ -135,7 +152,7 @@ static PyMethodDef test_methods[] = { {"tuple_get_size", tuple_get_size, METH_O}, {"tuple_get_item", tuple_get_item, METH_VARARGS}, {"tuple_set_item", tuple_set_item, METH_VARARGS}, - {"_tuple_resize", _tuple_resize, METH_VARARGS}, + {"_tuple_resize", _PyCFunction_CAST(_tuple_resize), METH_VARARGS | METH_KEYWORDS}, {"_check_tuple_item_is_NULL", _check_tuple_item_is_NULL, METH_VARARGS}, {"tuple_fromarray", tuple_fromarray, METH_VARARGS}, {NULL}, diff --git a/Objects/tupleobject.c b/Objects/tupleobject.c index 627147e585c7184..45ba55a1c203e92 100644 --- a/Objects/tupleobject.c +++ b/Objects/tupleobject.c @@ -1015,49 +1015,12 @@ PyTypeObject PyTuple_Type = { ._tp_iteritem = tuple_iteritem, }; -/* The following function breaks the notion that tuples are immutable: - it changes the size of a tuple. We get away with this only if there - is only one module referencing the object. You can also think of it - as creating a new tuple object and destroying the old one, only more - efficiently. In any case, don't use this if the tuple may already be - known to some other part of the code. */ -int -_PyTuple_Resize(PyObject **pv, Py_ssize_t newsize) +static int +tuple_resize_inplace(PyObject **pv, Py_ssize_t newsize) { - PyTupleObject *v; - PyTupleObject *sv; - Py_ssize_t i; - Py_ssize_t oldsize; + PyTupleObject *v = (PyTupleObject *) *pv; - v = (PyTupleObject *) *pv; - if (v == NULL || !Py_IS_TYPE(v, &PyTuple_Type) || newsize < 0 || - (Py_SIZE(v) != 0 && !_PyObject_IsUniquelyReferenced(*pv))) { - *pv = 0; - Py_XDECREF(v); - PyErr_BadInternalCall(); - return -1; - } - - oldsize = Py_SIZE(v); - if (oldsize == newsize) { - return 0; - } - if (newsize == 0) { - Py_DECREF(v); - *pv = tuple_get_empty(); - return 0; - } - if (oldsize == 0) { -#ifdef Py_DEBUG - assert(v == &_Py_SINGLETON(tuple_empty)); -#endif - /* The empty tuple is statically allocated so we never - resize it in-place. */ - Py_DECREF(v); - *pv = PyTuple_New(newsize); - return *pv == NULL ? -1 : 0; - } /* Check for overflow */ if ((size_t)newsize > ((size_t)PY_SSIZE_T_MAX - (sizeof(PyTupleObject) - sizeof(PyObject *))) / sizeof(PyObject *)) { @@ -1074,34 +1037,109 @@ _PyTuple_Resize(PyObject **pv, Py_ssize_t newsize) _Py_ForgetReference((PyObject *) v); #endif /* DECREF items deleted by shrinkage */ - for (i = newsize; i < oldsize; i++) { + Py_ssize_t oldsize = Py_SIZE(v); + for (Py_ssize_t i = newsize; i < oldsize; i++) { Py_CLEAR(v->ob_item[i]); } _PyReftracerTrack((PyObject *)v, PyRefTracer_DESTROY); - sv = PyObject_GC_Resize(PyTupleObject, v, newsize); + + PyTupleObject *sv = PyObject_GC_Resize(PyTupleObject, v, newsize); if (sv == NULL) { *pv = NULL; #ifdef Py_REF_DEBUG _Py_DecRefTotal(_PyThreadState_GET()); #endif /* The failed reallocation leaves the original items owned by v. */ - i = oldsize; + Py_ssize_t i = oldsize; while (--i >= 0) { Py_XDECREF(v->ob_item[i]); } PyObject_GC_Del(v); return -1; } + _Py_NewReferenceNoTotal((PyObject *) sv); /* Zero out items added by growing */ - if (newsize > oldsize) + if (newsize > oldsize) { memset(&sv->ob_item[oldsize], 0, - sizeof(*sv->ob_item) * (newsize - oldsize)); + sizeof(sv->ob_item[0]) * (newsize - oldsize)); + } + *pv = (PyObject *) sv; _PyObject_GC_TRACK(sv); return 0; } + +/* The following function breaks the notion that tuples are immutable: + it changes the size of a tuple. We get away with this only if there + is only one module referencing the object. You can also think of it + as creating a new tuple object and destroying the old one, only more + efficiently. In any case, don't use this if the tuple may already be + known to some other part of the code. */ + +int +_PyTuple_Resize(PyObject **pv, Py_ssize_t newsize) +{ + PyTupleObject *v = (PyTupleObject *) *pv; + if (v == NULL || !Py_IS_TYPE(v, &PyTuple_Type) || newsize < 0 || + (Py_SIZE(v) != 0 && !_PyObject_IsUniquelyReferenced(*pv))) { + *pv = 0; + Py_XDECREF(v); + PyErr_BadInternalCall(); + return -1; + } + + Py_ssize_t oldsize = Py_SIZE(v); + if (oldsize == newsize) { + return 0; + } + + if (newsize == 0) { + *pv = tuple_get_empty(); + Py_DECREF(v); + return 0; + } + + if (oldsize == 0) { +#ifdef Py_DEBUG + assert(v == &_Py_SINGLETON(tuple_empty)); +#endif + /* The empty tuple is statically allocated so we never + resize it in-place. */ + *pv = PyTuple_New(newsize); + Py_DECREF(v); + return *pv == NULL ? -1 : 0; + } + + // gh-161226: Return a copy if the hash value was already computed + if (v->ob_hash != -1) { + PyTupleObject *new_tuple = tuple_alloc(newsize); + if (new_tuple == NULL) { + *pv = NULL; + Py_DECREF(v); + return -1; + } + // Copy items + Py_ssize_t copy = Py_MIN(oldsize, newsize); + for (Py_ssize_t i = 0; i < copy; i++) { + new_tuple->ob_item[i] = Py_NewRef(v->ob_item[i]); + } + if (newsize > oldsize) { + // Set new items to NULL + memset(&new_tuple->ob_item[oldsize], 0, + (newsize - oldsize) * sizeof(new_tuple->ob_item[0])); + } + _PyObject_GC_TRACK(new_tuple); + + *pv = _PyObject_CAST(new_tuple); + Py_DECREF(v); + return 0; + } + + return tuple_resize_inplace(pv, newsize); +} + /*********************** Tuple Iterator **************************/ #define _PyTupleIterObject_CAST(op) ((_PyTupleIterObject *)(op))