From 0071853b359d909b6050115016d9e0e590003f9c Mon Sep 17 00:00:00 2001 From: wlkr73 <86650480+wlkr73@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:08:00 +0000 Subject: [PATCH] gh-160427: Return None from TextIOWrapper.buffer once detached gh-157364 replaced the buffer member descriptor with a getter that goes through buffer_access_safe(), which raises ValueError when the buffer is NULL. The attribute therefore raised instead of returning None after detach(), breaking hasattr() in 3.14.9 and 3.15.1 for code that probes a stream for a buffer. Return None from the getter when the wrapper is detached but initialised, which is what the member descriptor did before gh-157364 and what _pyio returns. The safety helper keeps raising for every other caller. --- Lib/test/test_io/test_textio.py | 10 +++++++--- .../2026-10-23-19-02-11.gh-issue-160427.k3x9aq.rst | 4 ++++ Modules/_io/textio.c | 14 +++++++++++++- 3 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-23-19-02-11.gh-issue-160427.k3x9aq.rst diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index cd3abd49ba8dde..b34c253a8b3a50 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -260,6 +260,10 @@ def test_detach(self): self.assertFalse(t.line_buffering) self.assertFalse(t.write_through) + # The attribute is None once detached, not an error (gh-160427). + self.assertIsNone(t.buffer) + self.assertTrue(hasattr(t, "buffer")) + def test_repr(self): raw = self.BytesIO("hello".encode("utf-8")) b = self.BufferedReader(raw) @@ -1710,9 +1714,9 @@ def readinto(self, b): self.BufferedReader(raw), encoding="utf-8") method = getattr(wrapper, method_name) self.assertEqual(method(), "ab\n") - with self.assertRaisesRegex(ValueError, - "underlying buffer has been detached"): - getattr(wrapper, 'buffer') + # gh-160427: once detached the attribute is None, not an + # error; the read above still had to complete safely. + self.assertIsNone(wrapper.buffer) def test_reentrant_seek_during_tell(self): # gh-153539: reading short of _CHUNK_SIZE leaves residual bytes in the diff --git a/Misc/NEWS.d/next/Library/2026-10-23-19-02-11.gh-issue-160427.k3x9aq.rst b/Misc/NEWS.d/next/Library/2026-10-23-19-02-11.gh-issue-160427.k3x9aq.rst new file mode 100644 index 00000000000000..6130200db951d4 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-23-19-02-11.gh-issue-160427.k3x9aq.rst @@ -0,0 +1,4 @@ +Fix ``io.TextIOWrapper.buffer`` to return ``None`` after the wrapper has been +detached, instead of raising :exc:`ValueError`. This restores the behaviour of +Python 3.14.8 and earlier, and stops :func:`hasattr` from raising when called on +a detached wrapper. diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index 7523b516f49eb5..1a265eed622ac8 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -3467,7 +3467,19 @@ static PyObject * _io_TextIOWrapper_buffer_get_impl(textio *self) /*[clinic end generated code: output=d265a34555aa5d4b input=5951cfa148f7350a]*/ { - return buffer_access_safe(self); + if (self->buffer == NULL) { + if (self->ok <= 0) { + PyErr_SetString(PyExc_ValueError, + "I/O operation on uninitialized object"); + return NULL; + } + /* Detached: the attribute is None, as it was while it was a member + descriptor (gh-160427). Raising here broke hasattr() in 3.14.9. */ + Py_RETURN_NONE; + } + + _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(self); + return Py_NewRef(self->buffer); } static PyMethodDef incrementalnewlinedecoder_methods[] = {