diff --git a/Lib/_pyio.py b/Lib/_pyio.py index 370b518f1e5e5a6..b380badcf213145 100644 --- a/Lib/_pyio.py +++ b/Lib/_pyio.py @@ -2271,7 +2271,10 @@ def writable(self): def flush(self): self.buffer.flush() - self._telling = self._seekable + # The snapshot is stale after next(), so only re-enable tell() when + # it needs neither the snapshot nor pending decoded text. + if self._snapshot is None and not self._decoded_chars: + self._telling = self._seekable def close(self): if self.buffer is not None and not self.closed: @@ -2568,6 +2571,7 @@ def _reset_encoder(position): position = self.buffer.seek(0, whence) self._set_decoded_chars('') self._snapshot = None + self._telling = self._seekable if self._decoder: self._decoder.reset() _reset_encoder(position) @@ -2587,6 +2591,7 @@ def _reset_encoder(position): self.buffer.seek(start_pos) self._set_decoded_chars('') self._snapshot = None + self._telling = self._seekable # Restore the decoder to its state from the safe start point. if cookie == 0 and self._decoder: diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index a18386d68569ae9..1e94e008b1033f2 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -597,6 +597,29 @@ def test_telling(self): self.assertEqual(f.tell(), p2) f.close() + def test_tell_after_next_and_flush(self): + # next() stops updating the decoder snapshot, and flush() must not + # re-enable tell() with that stale snapshot. + data = b"a\n" + b"b" * 10000 + b"\nc\n" + for prepare in ["none", "readline", "seek"]: + with self.subTest(prepare=prepare): + f = self.TextIOWrapper(self.BytesIO(data), encoding="ascii") + if prepare != "none": + self.assertEqual(f.readline(), "a\n") + if prepare == "seek": + f.seek(f.tell()) + next(f) + f.flush() + self.assertRaises(OSError, f.tell) + self.assertEqual(list(f)[-1], "c\n") + self.assertEqual(f.tell(), len(data)) + f.seek(0) + next(f) + self.assertRaises(OSError, f.tell) + f.seek(2) + self.assertEqual(f.tell(), 2) + self.assertEqual(f.read(3), "bbb") + def test_seeking(self): chunk_size = _default_chunk_size() prefix_size = chunk_size - 2 diff --git a/Misc/NEWS.d/next/Library/2026-11-01-02-30-51.gh-issue-161055.qPmPSU.rst b/Misc/NEWS.d/next/Library/2026-11-01-02-30-51.gh-issue-161055.qPmPSU.rst new file mode 100644 index 000000000000000..5d63ecc0f09cc0a --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-11-01-02-30-51.gh-issue-161055.qPmPSU.rst @@ -0,0 +1,3 @@ +Fix :meth:`io.TextIOWrapper.tell` returning a wrong position after +:func:`next` and :meth:`~io.IOBase.flush`. It now raises :exc:`OSError`, as it +does without the :meth:`!flush` call. diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index 4ef5251d451a4cd..b5565371e01f731 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -2712,6 +2712,7 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) textiowrapper_set_decoded_chars(self, NULL); Py_CLEAR(self->snapshot); + self->telling = self->seekable; if (self->decoder) { res = PyObject_CallMethodNoArgs(self->decoder, &_Py_ID(reset)); if (res == NULL) @@ -2780,6 +2781,7 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) textiowrapper_set_decoded_chars(self, NULL); Py_CLEAR(self->snapshot); + self->telling = self->seekable; /* Restore the decoder to its state from the safe start point. */ if (self->decoder) { @@ -3256,7 +3258,14 @@ _io_TextIOWrapper_flush_impl(textio *self) { CHECK_ATTACHED(self); CHECK_CLOSED(self); - self->telling = self->seekable; + /* The snapshot is stale after next(), so only re-enable tell() when + it needs neither the snapshot nor pending decoded text. */ + if (self->snapshot == NULL && + (self->decoded_chars == NULL || + PyUnicode_GET_LENGTH(self->decoded_chars) == 0)) + { + self->telling = self->seekable; + } if (_textiowrapper_writeflush(self) < 0) return NULL; return buffer_callmethod_noargs(self, &_Py_ID(flush));