From 643254e7976c4e27ebb0acaca4992751d3b918cc Mon Sep 17 00:00:00 2001 From: PhysicistJohn <54456354+PhysicistJohn@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:54:15 -0700 Subject: [PATCH 1/3] gh-154570: Fix itertools.accumulate() silently corrupting its total on reentrancy A source iterable or func= callable that calls back into next() on the same accumulate object mid-step silently corrupted the running total instead of erroring. Adds a running guard field to accumulateobject, mirroring the existing pattern already used by teedataobject for the identical class of bug (see teedataobject_getitem_lock_held). accumulate now raises RuntimeError("cannot re-enter the accumulate iterator") on reentrant access instead of silently corrupting state, matching tee's existing behavior for the same defect class. Adds a regression test (test_accumulate_reenter) in the same style as the existing test_tee_reenter. --- Lib/test/test_itertools.py | 19 +++++++++++++++++++ ....gh-issue-154570.accumulate-reentrancy.rst | 6 ++++++ Modules/itertoolsmodule.c | 14 +++++++++++++- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-23-22-00-00.gh-issue-154570.accumulate-reentrancy.rst diff --git a/Lib/test/test_itertools.py b/Lib/test/test_itertools.py index cf579d4da4e0dfb..4978f4311b113e4 100644 --- a/Lib/test/test_itertools.py +++ b/Lib/test/test_itertools.py @@ -157,6 +157,25 @@ def test_accumulate(self): with self.assertRaises(TypeError): list(accumulate([10, 20], 100)) + def test_accumulate_reenter(self): + # A source iterable (or binop) that calls back into next() on the + # same accumulate object must not be allowed to silently corrupt + # the running total. + class I: + count = 0 + def __iter__(self): + return self + def __next__(self): + self.count += 1 + if self.count == 2: + return next(it) + return self.count + + it = accumulate(I()) + self.assertEqual(next(it), 1) + with self.assertRaisesRegex(RuntimeError, "accumulate"): + next(it) + def test_batched(self): self.assertEqual(list(batched('ABCDEFG', 3)), [('A', 'B', 'C'), ('D', 'E', 'F'), ('G',)]) diff --git a/Misc/NEWS.d/next/Library/2026-07-23-22-00-00.gh-issue-154570.accumulate-reentrancy.rst b/Misc/NEWS.d/next/Library/2026-07-23-22-00-00.gh-issue-154570.accumulate-reentrancy.rst new file mode 100644 index 000000000000000..9c90c55495cb5ee --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-23-22-00-00.gh-issue-154570.accumulate-reentrancy.rst @@ -0,0 +1,6 @@ +Fix :func:`itertools.accumulate` silently corrupting its running total +when the source iterable (or the ``func`` callable) re-enters the same +:func:`!accumulate` iterator via a nested call to :func:`!next`. It now +raises :exc:`RuntimeError` on re-entry instead of producing wrong, +silently-dropped values, matching the existing behavior of +:func:`itertools.tee`. diff --git a/Modules/itertoolsmodule.c b/Modules/itertoolsmodule.c index c0023c839ca7fe3..3552f3b178860a8 100644 --- a/Modules/itertoolsmodule.c +++ b/Modules/itertoolsmodule.c @@ -3079,6 +3079,7 @@ typedef struct { PyObject *binop; PyObject *initial; itertools_state *state; + int running; } accumulateobject; #define accumulateobject_CAST(op) ((accumulateobject *)(op)) @@ -3120,6 +3121,7 @@ itertools_accumulate_impl(PyTypeObject *type, PyObject *iterable, lz->it = it; lz->initial = Py_XNewRef(initial); lz->state = find_state_by_type(type); + lz->running = 0; return (PyObject *)lz; } @@ -3160,12 +3162,21 @@ accumulate_next_lock_held(PyObject *op) lz->initial = Py_NewRef(Py_None); return Py_NewRef(lz->total); } + if (lz->running) { + PyErr_SetString(PyExc_RuntimeError, + "cannot re-enter the accumulate iterator"); + return NULL; + } + lz->running = 1; val = (*Py_TYPE(lz->it)->tp_iternext)(lz->it); - if (val == NULL) + if (val == NULL) { + lz->running = 0; return NULL; + } if (lz->total == NULL) { lz->total = Py_NewRef(val); + lz->running = 0; return lz->total; } @@ -3174,6 +3185,7 @@ accumulate_next_lock_held(PyObject *op) else newtotal = PyObject_CallFunctionObjArgs(lz->binop, lz->total, val, NULL); Py_DECREF(val); + lz->running = 0; if (newtotal == NULL) return NULL; From 93ddb18e395e21bc1dd824e7f29374b605ace69f Mon Sep 17 00:00:00 2001 From: PhysicistJohn <54456354+PhysicistJohn@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:13:53 -0700 Subject: [PATCH 2/3] Address review feedback: trim comment/NEWS, uint8_t running, add func-path reentrancy test Per brijkapadia's review on GH-154571: - Remove the explanatory comment above test_accumulate_reenter. - Change accumulateobject.running from int to uint8_t (no call sites needed changes; assigning 0/1 to a uint8_t requires no source edit). - Shorten the NEWS entry, keeping one clause on what was actually broken (silent corruption) rather than only the new behavior. Also add test_accumulate_reenter_func, covering reentrancy triggered via the func callable rather than the source iterable -- the guard protects both paths but only the iterable path had a test. teedataobject.running (same file, same purpose, for itertools.tee) is deliberately left as int rather than also narrowed, to avoid scope creep beyond what was requested. --- Lib/test/test_itertools.py | 12 +++++++++--- ...2-00-00.gh-issue-154570.accumulate-reentrancy.rst | 6 +----- Modules/itertoolsmodule.c | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Lib/test/test_itertools.py b/Lib/test/test_itertools.py index 4978f4311b113e4..677b66fe703e1cc 100644 --- a/Lib/test/test_itertools.py +++ b/Lib/test/test_itertools.py @@ -158,9 +158,6 @@ def test_accumulate(self): list(accumulate([10, 20], 100)) def test_accumulate_reenter(self): - # A source iterable (or binop) that calls back into next() on the - # same accumulate object must not be allowed to silently corrupt - # the running total. class I: count = 0 def __iter__(self): @@ -176,6 +173,15 @@ def __next__(self): with self.assertRaisesRegex(RuntimeError, "accumulate"): next(it) + def test_accumulate_reenter_func(self): + def reenter(a, b): + return next(it) + + it = accumulate([1, 2, 3], reenter) + self.assertEqual(next(it), 1) + with self.assertRaisesRegex(RuntimeError, "accumulate"): + next(it) + def test_batched(self): self.assertEqual(list(batched('ABCDEFG', 3)), [('A', 'B', 'C'), ('D', 'E', 'F'), ('G',)]) diff --git a/Misc/NEWS.d/next/Library/2026-07-23-22-00-00.gh-issue-154570.accumulate-reentrancy.rst b/Misc/NEWS.d/next/Library/2026-07-23-22-00-00.gh-issue-154570.accumulate-reentrancy.rst index 9c90c55495cb5ee..116dc6d2d7083aa 100644 --- a/Misc/NEWS.d/next/Library/2026-07-23-22-00-00.gh-issue-154570.accumulate-reentrancy.rst +++ b/Misc/NEWS.d/next/Library/2026-07-23-22-00-00.gh-issue-154570.accumulate-reentrancy.rst @@ -1,6 +1,2 @@ Fix :func:`itertools.accumulate` silently corrupting its running total -when the source iterable (or the ``func`` callable) re-enters the same -:func:`!accumulate` iterator via a nested call to :func:`!next`. It now -raises :exc:`RuntimeError` on re-entry instead of producing wrong, -silently-dropped values, matching the existing behavior of -:func:`itertools.tee`. +on reentrancy; it now raises :exc:`RuntimeError` instead. diff --git a/Modules/itertoolsmodule.c b/Modules/itertoolsmodule.c index 3552f3b178860a8..3f95761df377cc1 100644 --- a/Modules/itertoolsmodule.c +++ b/Modules/itertoolsmodule.c @@ -3079,7 +3079,7 @@ typedef struct { PyObject *binop; PyObject *initial; itertools_state *state; - int running; + uint8_t running; } accumulateobject; #define accumulateobject_CAST(op) ((accumulateobject *)(op)) From 69b8a823977733cef6c673f413fd9709d6f66808 Mon Sep 17 00:00:00 2001 From: PhysicistJohn <54456354+PhysicistJohn@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:34:14 -0700 Subject: [PATCH 3/3] Retrigger CI: Android/EPUB failures were GitHub release-asset 504s, unrelated to this change Signed-off-by: PhysicistJohn <54456354+PhysicistJohn@users.noreply.github.com>