From e0c66fe19afcf4a386836ab02217b0d1ffa70423 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Wed, 28 Oct 2026 16:41:08 +0800 Subject: [PATCH] gh-160811: fix TextIOWrapper.tell() after seek() past a carriage return seek() to a cookie with chars_to_skip replayed the input chunk without updating the bytes-per-char ratio, so tell() could compute skip_bytes larger than the snapshot buffer. In the C implementation this read past the end of the buffer, and _pyio returned a wrong position. --- Lib/_pyio.py | 4 ++++ Lib/test/test_io/test_textio.py | 16 ++++++++++++++++ ...026-10-28-08-12-33.gh-issue-160811.wQ3xT7.rst | 4 ++++ Modules/_io/textio.c | 8 ++++++++ 4 files changed, 32 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-10-28-08-12-33.gh-issue-160811.wQ3xT7.rst diff --git a/Lib/_pyio.py b/Lib/_pyio.py index 370b518f1e5e5a..93935c08fffe3f 100644 --- a/Lib/_pyio.py +++ b/Lib/_pyio.py @@ -2602,6 +2602,10 @@ def _reset_encoder(position): self._set_decoded_chars( self._decoder.decode(input_chunk, need_eof)) self._snapshot = (dec_flags, input_chunk) + if self._decoded_chars: + self._b2cratio = len(input_chunk) / len(self._decoded_chars) + else: + self._b2cratio = 0.0 # Skip chars_to_skip of the decoded characters. if len(self._decoded_chars) < chars_to_skip: diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index a18386d68569ae..0fad48545049ae 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -705,6 +705,22 @@ def test_tell_after_readline_with_cr(self): remaining = f.read() self.assertEqual(remaining, "") + def test_tell_after_seek_past_cr(self): + # seek() to a position after a carriage return must not leave a + # stale bytes-per-char ratio behind for tell(). + for data in (b'one\rtwo\rthree\r', 'ab\r\u20ac\u20ac\u20ac'.encode()): + expected = data.decode().replace('\r', '\n') + for i in range(len(expected)): + with self.subTest(data=data, i=i): + f = self.TextIOWrapper(self.BytesIO(data), + encoding='utf-8') + f.read(i) + pos = f.tell() + f.read(1) + f.seek(pos) + self.assertEqual(f.read(1), expected[i]) + f.seek(f.tell()) + self.assertEqual(f.read(), expected[i + 1:]) def test_seek_with_encoder_state(self): f = self.open(os_helper.TESTFN, "w", encoding="euc_jis_2004") diff --git a/Misc/NEWS.d/next/Library/2026-10-28-08-12-33.gh-issue-160811.wQ3xT7.rst b/Misc/NEWS.d/next/Library/2026-10-28-08-12-33.gh-issue-160811.wQ3xT7.rst new file mode 100644 index 00000000000000..8488819d415ae8 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-28-08-12-33.gh-issue-160811.wQ3xT7.rst @@ -0,0 +1,4 @@ +Fix :meth:`io.TextIOWrapper.tell` after :meth:`~io.TextIOWrapper.seek` to a +position that follows a carriage return. The bytes-per-char ratio was not +updated when the input chunk was replayed, so ``tell()`` could read past an +internal buffer and return a wrong position. diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index 4ef5251d451a4c..4ba5425f4534f9 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -2812,6 +2812,7 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) goto fail; } + Py_ssize_t nbytes = PyBytes_GET_SIZE(input_chunk); snapshot = Py_BuildValue("iN", cookie.dec_flags, input_chunk); if (snapshot == NULL) { goto fail; @@ -2824,7 +2825,14 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) if (check_decoded(decoded) < 0) goto fail; + Py_ssize_t nchars = PyUnicode_GET_LENGTH(decoded); textiowrapper_set_decoded_chars(self, decoded); + if (nchars > 0) { + self->b2cratio = (double) nbytes / nchars; + } + else { + self->b2cratio = 0.0; + } /* Skip chars_to_skip of the decoded characters. */ if (PyUnicode_GetLength(self->decoded_chars) < cookie.chars_to_skip) {