为什么TextIOWrapper会关闭给定的BytesIO流?

9
如果在Python 3中运行以下代码:
from io import BytesIO
import csv
from io import TextIOWrapper


def fill_into_stringio(input_io):
    writer = csv.DictWriter(TextIOWrapper(input_io, encoding='utf-8'),fieldnames=['ids'])
    for i in range(100):
        writer.writerow({'ids': str(i)})

with BytesIO() as input_i:
    fill_into_stringio(input_i)
    input_i.seek(0)

I get an error:

ValueError: I/O operation on closed file.

如果我不使用TextIOWrapper,则io流将保持打开状态。例如,如果我修改我的函数为

def fill_into_stringio(input_io):
    for i in range(100):
        input_io.write(b'erwfewfwef')

我不再遇到任何错误了,但某种原因导致TestIOWrapper在我之后想要读取的流关闭了。这是预期的吗?如果是,有没有一种方法可以在不自己编写csv写入器的情况下实现我正在尝试的内容?


省略的回溯显示被拒绝的I/O操作是最后一个seek(0),而不是fill_into_stringio中的任何writerow操作。 - Terry Jan Reedy
2个回答

18

csv 模块是这里的奇怪之处;大多数包装其他对象的类文件对象都会假定拥有该对象,当它们自己被关闭(或以其他方式清理)时关闭它。

避免这个问题的一种方法是在允许其被清理之前明确地从 TextIOWrapper 中分离出来:

def fill_into_stringio(input_io):
    # write_through=True prevents TextIOWrapper from buffering internally;
    # you could replace it with explicit flushes, but you want something 
    # to ensure nothing is left in the TextIOWrapper when you detach
    text_input = TextIOWrapper(input_io, encoding='utf-8', write_through=True)
    try:
        writer = csv.DictWriter(text_input, fieldnames=['ids'])
        for i in range(100):
            writer.writerow({'ids': str(i)})
    finally:
        text_input.detach()  # Detaches input_io so it won't be closed when text_input cleaned up

唯一避免这种情况的内置方法是使用真实文件对象,在其中你可以传递文件描述符和 closefd=False,这样当 close 或其他清理操作时它们就不会关闭底层文件描述符。

当然,在您特定的情况下,还有更简单的方法:只需让您的函数期望基于文本的文件对象并直接使用它们,而无需重新包装;您的函数确实不应该负责强制调用者在输出文件上施加编码(如果调用者想要UTF-16输出怎么办?)。

然后,您可以执行以下操作:

from io import StringIO

def fill_into_stringio(input_io):
    writer = csv.DictWriter(input_io, fieldnames=['ids'])
    for i in range(100):
        writer.writerow({'ids': str(i)})

# newline='' is the Python 3 way to prevent line-ending translation
# while continuing to operate as text, and it's recommended for any file
# used with the csv module
with StringIO(newline='') as input_i:
    fill_into_stringio(input_i)
    input_i.seek(0)
    # If you really need UTF-8 bytes as output, you can make a BytesIO at this point with:
    # BytesIO(input_i.getvalue().encode('utf-8'))

BytesIO(input_i.getvalue().encode('utf-8'))会复制整个内容并对其进行编码吗? 流将有很多行,因此如果我之后不必进行翻译,则速度会更快,如果它进行了复制,那么我可能会遇到内存问题,我将不得不通过引入一半大小的块来解决它。 - Yannick Widmer
@YannickSSE:它确实会进行完全复制,但如果您真的接近内存问题的边缘,则正确的解决方案是切换到真正的类似文件的对象(例如tempfile.TemporaryFile),以便可以溢出到磁盘。您始终可以使用detach方法而不会使内存翻倍,但想要将CSV(自然文本导向)表示为二进制数据非常奇怪;个人而言,我会一直使用文本类型。 - ShadowRanger
我想我明白你的意思,但我的做法是从一个数据源获取数据,生成一些新数据并以csv格式发送到服务器进行存储。因此,我不想在本地存储文件。我发送到的服务器不是我的,它有一个期望字节的API。 - Yannick Widmer
@YannickSSE:tempfile.TemporaryFile 就是为此而生的;它由磁盘支持,但在磁盘上没有名称,当它关闭时,数据就会消失。当内存可用时,它比 BytesIO/StringIO 慢(因为它使用真正的系统调用),但它也不受内存限制。仅仅因为它在磁盘上并不意味着它会永远存在;它使用磁盘作为临时存储,完成后就会被删除。 - ShadowRanger
这几乎让我发疯了。感谢你的回答,你真是个救星。 - pietz

0
我在尝试将stdout重定向到PyQT5中的GUI文本框时,遇到了相同的“ValueError: I/O operation on closed file.”错误。虽然是稍微不同的应用程序,但是出现了相同的底层错误。基本上,当没有对TextIOWrapper()对象的引用时,垃圾回收器会删除它,并在此过程中关闭底层流。
在函数执行完毕后,您的代码中不再有对TextIOWrapper对象的引用,因此在执行input_i.seek(0)之前,垃圾回收器会将其删除。作为删除TextIOWrapper对象的一部分,垃圾回收器会关闭被包装的缓冲区。当您再次访问被包装的流时,流已经关闭并引发错误。
鉴于这种行为,我认为将stdout缓冲区与TextIOWrapper包装通常是一个不好的主意,因为一旦TextIOWrapper对象被垃圾回收器删除,它就会关闭原始的stdout流。
在我的情况下,我继承了StringIO类,使得写入方法触发附加到另一个文本框的pyqt信号(信号用于实现线程安全的数据传输),因为StringIO有自己的内存缓冲区,在垃圾回收过程中不会影响底层缓冲区(如stdout),从而意外关闭它。经过进一步考虑,也许下次我可以继承io模块中的抽象基类之一。
class StdOutRedirector(io.StringIO):
    def __init__(self, update_ui: pyqtSignal):
        super().__init__()
        self.update_ui = update_ui

    def write(self, string):
        self.update_ui.emit(string)

try:
    sys.stdout = StdOutRedirector(self.send_text)
    doSomeStuffWithRedirectedStdout()
except Exception as error:
    tell_user("bug happened")
finally:
    sys.stdout = sys.__stdout__

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接