From 32ca4f8c278947c9d5c4dad8181f82b1e5aef895 Mon Sep 17 00:00:00 2001 From: Inada Naoki Date: Wed, 4 Nov 2026 10:21:09 +0000 Subject: [PATCH 1/2] gh-85393: Handle partial writes in TextIOWrapper --- Lib/_pyio.py | 10 +++- Lib/test/test_io/test_textio.py | 21 +++++++++ ...6-11-04-10-14-40.gh-issue-85393.GWdV0X.rst | 2 + Modules/_io/textio.c | 47 +++++++++++++++---- 4 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-11-04-10-14-40.gh-issue-85393.GWdV0X.rst diff --git a/Lib/_pyio.py b/Lib/_pyio.py index 370b518f1e5e5a..e17600287770ee 100644 --- a/Lib/_pyio.py +++ b/Lib/_pyio.py @@ -2308,7 +2308,15 @@ def write(self, s): encoder = self._encoder or self._get_encoder() # XXX What if we were just reading? b = encoder.encode(s) - self.buffer.write(b) + while True: + n = self.buffer.write(b) + if n is None: + raise BlockingIOError("write returned None") + if n < 0 or n > len(b) or (n == 0 and b): + raise OSError("write() returned incorrect number of bytes") + b = b[n:] + if not b: + break if self._line_buffering and (haslf or "\r" in s): self.flush() if self._snapshot is not None: diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index a18386d68569ae..b1b14efe1c9707 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -1022,6 +1022,27 @@ def test_rawio_write_through(self): txt.write('5') self.assertEqual(b''.join(raw._write_stack), b'123\n45') + def test_rawio_short_write(self): + class ShortWriter(self.MockRawIO): + def write(self, b): + return super().write(b[:2]) + + raw = ShortWriter() + with self.TextIOWrapper(raw, encoding='utf-8', write_through=True) as txt: + self.assertEqual(txt.write('a\u20acb'), 3) + self.assertEqual(b''.join(raw._write_stack), b'a\xe2\x82\xacb') + + def test_rawio_write_invalid_result(self): + for result, error in ((0, OSError), (-1, OSError), (4, OSError), + (None, BlockingIOError), ('1', TypeError), + (1.5, TypeError)): + with self.subTest(result=result): + raw = self.MockRawIO() + raw.write = lambda b: result + with self.TextIOWrapper(raw, encoding='ascii', + write_through=True) as txt: + self.assertRaises(error, txt.write, 'abc') + def test_bufio_write_through(self): # Issue #21396: write_through=True doesn't force a flush() # on the underlying binary buffered object. diff --git a/Misc/NEWS.d/next/Library/2026-11-04-10-14-40.gh-issue-85393.GWdV0X.rst b/Misc/NEWS.d/next/Library/2026-11-04-10-14-40.gh-issue-85393.GWdV0X.rst new file mode 100644 index 00000000000000..5bc9324b4d90ac --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-11-04-10-14-40.gh-issue-85393.GWdV0X.rst @@ -0,0 +1,2 @@ +Fix :class:`io.TextIOWrapper` silently losing data when the underlying +binary stream only writes part of the data. diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index 4ef5251d451a4c..c3c3c549dd9c2f 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -1726,17 +1726,48 @@ _textiowrapper_writeflush(textio *self) self->pending_bytes = NULL; Py_DECREF(pending); - PyObject *ret; - do { - ret = buffer_callmethod_onearg(self, &_Py_ID(write), b); - } while (ret == NULL && _PyIO_trap_eintr()); + while (1) { + PyObject *ret; + do { + ret = buffer_callmethod_onearg(self, &_Py_ID(write), b); + } while (ret == NULL && _PyIO_trap_eintr()); + if (ret == NULL) { + goto error; + } + if (ret == Py_None) { + Py_DECREF(ret); + PyErr_SetString(PyExc_BlockingIOError, "write returned None"); + goto error; + } + Py_ssize_t n = PyNumber_AsSsize_t(ret, PyExc_OverflowError); + Py_DECREF(ret); + if (n == -1 && PyErr_Occurred()) { + goto error; + } + Py_ssize_t len = PyBytes_GET_SIZE(b); + if (n <= 0 || n > len) { + PyErr_SetString(PyExc_OSError, + "write() returned incorrect number of bytes"); + goto error; + } + if (n == len) { + break; + } + PyObject *remaining = PyBytes_FromStringAndSize( + PyBytes_AS_STRING(b) + n, len - n); + if (remaining == NULL) { + goto error; + } + Py_SETREF(b, remaining); + } Py_DECREF(b); + return 0; + +error: // NOTE: We cleared buffer but we don't know how many bytes are actually written // when an error occurred. - if (ret == NULL) - return -1; - Py_DECREF(ret); - return 0; + Py_DECREF(b); + return -1; } /* Return borrowed Py_None on success, or NULL on error. */ From 4668e1518723ccf666dc5c695d4853c0c594acbb Mon Sep 17 00:00:00 2001 From: Inada Naoki Date: Wed, 4 Nov 2026 12:31:59 +0000 Subject: [PATCH 2/2] gh-85393: Preserve compatibility with writers returning None --- Lib/_pyio.py | 3 ++- Lib/test/test_io/test_textio.py | 10 ++++++++-- Modules/_io/textio.c | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Lib/_pyio.py b/Lib/_pyio.py index e17600287770ee..bb45157e166a35 100644 --- a/Lib/_pyio.py +++ b/Lib/_pyio.py @@ -2311,7 +2311,8 @@ def write(self, s): while True: n = self.buffer.write(b) if n is None: - raise BlockingIOError("write returned None") + # Some legacy writers return None after writing all the data. + break if n < 0 or n > len(b) or (n == 0 and b): raise OSError("write() returned incorrect number of bytes") b = b[n:] diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index b1b14efe1c9707..701792306fb80a 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -1032,10 +1032,16 @@ def write(self, b): self.assertEqual(txt.write('a\u20acb'), 3) self.assertEqual(b''.join(raw._write_stack), b'a\xe2\x82\xacb') + def test_rawio_write_none(self): + raw = self.MockRawIO() + raw.write = raw._write_stack.append + with self.TextIOWrapper(raw, encoding='ascii', write_through=True) as txt: + self.assertEqual(txt.write('abc'), 3) + self.assertEqual(raw._write_stack, [b'abc']) + def test_rawio_write_invalid_result(self): for result, error in ((0, OSError), (-1, OSError), (4, OSError), - (None, BlockingIOError), ('1', TypeError), - (1.5, TypeError)): + ('1', TypeError), (1.5, TypeError)): with self.subTest(result=result): raw = self.MockRawIO() raw.write = lambda b: result diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index c3c3c549dd9c2f..7932d3c5d75c3d 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -1735,9 +1735,9 @@ _textiowrapper_writeflush(textio *self) goto error; } if (ret == Py_None) { + /* Some legacy writers return None after writing all the data. */ Py_DECREF(ret); - PyErr_SetString(PyExc_BlockingIOError, "write returned None"); - goto error; + break; } Py_ssize_t n = PyNumber_AsSsize_t(ret, PyExc_OverflowError); Py_DECREF(ret);