https://github.com/python/cpython/commit/1405607faf61d76cf00ab88f5d2e81c5fa1754ac
commit: 1405607faf61d76cf00ab88f5d2e81c5fa1754ac
branch: main
author: Victor Stinner <[email protected]>
committer: vstinner <[email protected]>
date: 2026-11-05T14:28:38+01:00
summary:

gh-161226: Fail if the hash is cached in _PyTuple_Resize() (#161293)

_PyTuple_Resize() now fails with SystemError if the hash value was
already computed.

Add compute_hash optional parameter to _testcapi._tuple_resize().

files:
A Misc/NEWS.d/next/C_API/2026-11-03-01-24-01.gh-issue-161226.MPrJKp.rst
M Doc/c-api/tuple.rst
M Lib/test/test_capi/test_tuple.py
M Modules/_testcapi/tuple.c
M Objects/tupleobject.c

diff --git a/Doc/c-api/tuple.rst b/Doc/c-api/tuple.rst
index e8be4762dc33a1..10929b233d6840 100644
--- a/Doc/c-api/tuple.rst
+++ b/Doc/c-api/tuple.rst
@@ -133,16 +133,36 @@ Tuple Objects
 
 .. c:function:: int _PyTuple_Resize(PyObject **p, Py_ssize_t newsize)
 
-   Can be used to resize a tuple.  *newsize* will be the new length of the 
tuple.
-   Because tuples are *supposed* to be immutable, this should only be used if 
there
-   is only one reference to the object.  Do *not* use this if the tuple may 
already
-   be known to some other part of the code.  The tuple will always grow or 
shrink
-   at the end.  Think of this as destroying the old tuple and creating a new 
one,
-   only more efficiently.  Returns ``0`` on success. Client code should never
-   assume that the resulting value of ``*p`` will be the same as before calling
-   this function. If the object referenced by ``*p`` is replaced, the original
-   ``*p`` is destroyed.  On failure, returns ``-1`` and sets ``*p`` to 
``NULL``, and
-   raises :exc:`MemoryError` or :exc:`SystemError`.
+   Resize a tuple.
+   *newsize* will be the new length of the tuple.
+
+   Because tuples are *supposed* to be immutable, this should only be used if
+   there is only one reference to the object.
+   Do *not* use this if the tuple may already be known to some other part of
+   the code.
+   The tuple hash value must not be computed yet.
+
+   The tuple will always grow or shrink at the end.
+   Think of this as destroying the old tuple and creating a new one, only more
+   efficiently.
+
+   Returns ``0`` on success.
+   Client code should never assume that the resulting value of ``*p`` will be
+   the same as before calling this function.
+   If the object referenced by ``*p`` is replaced, the original ``*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.
+
+   .. versionchanged:: next
+      The function now fails if the tuple hash value was already computed.
 
 
 .. _struct-sequence-objects:
diff --git a/Lib/test/test_capi/test_tuple.py b/Lib/test/test_capi/test_tuple.py
index fe5d694ea7d831..3f2318f303d57a 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,30 +299,71 @@ 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)
+        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:
+                # 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
+
+        # Resizing the empty tuple singleton always create a new tuple
+        for compute_hash in (False, True):
+            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)
         self.assertEqual(b, a)
-        b = resize(a, 2)
+        b = resize(a, 2, True)
         self.assertEqual(b, a[:2])
-        b = resize(a, 5)
+        b = resize(a, 5, True)
         self.assertEqual(len(b), 5)
         self.assertEqual(b[:3], a)
         self.assertTrue(checknull(b, 3))
         self.assertTrue(checknull(b, 4))
 
+        # gh-161226: Disallow resizing a tuple if the hash value is cached
+        a = tuple(object() for _ in range(3))
+        with self.assertRaises(SystemError):
+            resize(a, 3, True, compute_hash=True)
+
         a = ()
         self.assertRaises(MemoryError, resize, a, PY_SSIZE_T_MAX)
         self.assertRaises(SystemError, resize, a, -1)
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 00000000000000..601db29c66cd9b
--- /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 fails with :exc:`SystemError` if the hash value
+was already computed. Patch by Victor Stinner.
diff --git a/Modules/_testcapi/tuple.c b/Modules/_testcapi/tuple.c
index 5de1c494c0a8c0..c3a1218d7916e9 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 627147e585c718..68ca2772507295 100644
--- a/Objects/tupleobject.c
+++ b/Objects/tupleobject.c
@@ -1031,8 +1031,11 @@ _PyTuple_Resize(PyObject **pv, Py_ssize_t newsize)
     Py_ssize_t oldsize;
 
     v = (PyTupleObject *) *pv;
-    if (v == NULL || !Py_IS_TYPE(v, &PyTuple_Type) || newsize < 0 ||
-        (Py_SIZE(v) != 0 && !_PyObject_IsUniquelyReferenced(*pv))) {
+    if (v == NULL || !Py_IS_TYPE(v, &PyTuple_Type) || newsize < 0
+        || (Py_SIZE(v) != 0 && !_PyObject_IsUniquelyReferenced(*pv))
+        // gh-161226: Disallow resizing a tuple if the hash value is cached
+        || (Py_SIZE(v) != 0 && v->ob_hash != -1))
+    {
         *pv = 0;
         Py_XDECREF(v);
         PyErr_BadInternalCall();

_______________________________________________
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