何かを書き留める何か

数学や読んだ本について書く何かです。最近は社会人として生き残りの術を学ぶ日々です。

Pythonの io.BytesIO と zipfile.ZipFile の組み合わせ

with文がやってくれるのはどこまでなのか

インターネット経由で取得したZIPファイルを手元で加工する、という状況を考える。 たとえば、次のようなコードを書いたとする。 URL は ZIPファイルを取得できるものならば何でもよいが、今回は環境に配慮してローカルに1回だけダウンロードしてhttp.serverで簡易Webサーバを立てることでキャッシュしている。

import io
import urllib.request
import zipfile

URL = "http://localhost:8000/python-3.12.2-embed-amd64.zip"

with urllib.request.urlopen(URL) as f:
    content: bytes = f.read()

with zipfile.ZipFile(io.BytesIO(content)) as zf:
    names: list[str] = zf.namelist()

for name in names:
    print(name)

urllib.request.urlopen() 経由で取得したものはbytesであり、ファイルオブジェクトではない。 zipfile.ZipFile の第一引数file はファイルのパス、ファイルオブジェクト、 pathlib.Path のいずれかである必要があるため、urllib.request.urlopen() 経由で取得したものをそのまま渡すわけにはいかない。 そのため、 io.Bytes() を経由することでインメモリーストリームとして渡す。

このエントリの主題は、上記のコードにおける io.BytesIO(content) の取り扱いについてである。 with zipfile.ZipFile(io.BytesIO(content)) as zf: と記述しているので、 names: list[str] = zf.namelist() が完了したら zipfile.ZipFile(io.BytesIO(content))close() メソッドが呼ばれる。 その際に、中身である io.BytesIO(content) は閉じられるのだろうか。

公式ドキュメントとソースコードを巡る冒険

公式ドキュメントには

The buffer is discarded when the close() method is called.

とある通り、 BytesIOclose() メソッドが呼ばれなければインメモリーストリームのバッファは解放されない。

まず、 ソースコード にある __init__() から説明に必要な個所を抜き出す。

class ZipFile:
    fp = None                   # Set here since __del__ checks it

    def __init__(self, file, mode="r", compression=ZIP_STORED, allowZip64=True,
                 compresslevel=None, *, strict_timestamps=True, metadata_encoding=None):
        """Open the ZIP file with mode read 'r', write 'w', exclusive create 'x',
        or append 'a'."""
        ...

        # Check if we were passed a file-like object
        if isinstance(file, os.PathLike):
            file = os.fspath(file)
        if isinstance(file, str):
            # No, it's a filename
           ...
        else:
            self._filePassed = 1
            self.fp = file
            self.filename = getattr(file, 'name', None)
        self._fileRefCnt = 1
        ...

ここで認識してほしいのは以下の3点である。

  • zipfile.ZipFile の第一引数にファイルオブジェクトを渡すと self._filePassed = 1 となる。この変数は __init__()でのみ定義され、変更されない。
  • zipfile.ZipFile の第一引数にファイルオブジェクトを渡すと self.fp = file 、つまり self.fpBytesIOインスタンスが入る。
  • zipfile.ZipFile の第一引数にファイルオブジェクトを渡すと self._fileRefCnt = 1 となる。今回のスニペットだと self._fileRefCnt = 1 は1のままである。

次に ソースコード にある close() メソッドを確認する。

    def close(self):
        """Close the file, and for mode 'w', 'x' and 'a' write the ending
        records."""
        if self.fp is None:
            return
        try:
            ...
        finally:
            fp = self.fp
            self.fp = None
            self._fpclose(fp)

ここでわかることは以下の点である。

  • 内部変数fpself.fp 、つまり BytesIOインスタンスが入る。
  • その後、 self.fpNone となる。
  • self._fpclose(fp) が実行される。

そして、 ソースコード にある _fpclose()メソッドを確認する。

    def _fpclose(self, fp):
        assert self._fileRefCnt > 0
        self._fileRefCnt -= 1
        if not self._fileRefCnt and not self._filePassed:
            fp.close()
  • self._fileRefCnt = 1 なので、if 文に到達する際に `self._fileRefCnt == 0 となり、条件式の左辺は True である。
  • self._filePassed = 1 なので、条件式の右辺は False である。
  • よって、 if not self._fileRefCnt and not self._filePassed:False となり、 fp.close() は実行されない。

以上より、「 io.BytesIO(content) は閉じられるのだろうか」という疑問は「閉じられません」となる。

では、閉じるにはどうすればよいのか、自分で明示的に閉じるしかない。

with io.BytesIO(content) as bs:
    with zipfile.ZipFile(bs) as zf:
        names: list[str] = zf.namelist()

こうすれば、 names: list[str] = zf.namelist() が実行された後、zipfile.ZipFileが閉じられ、 io.BytesIO が閉じられる。 これで安心して眠りにつくことができる。

ガベージコレクションの存在

しかし、このスニペット程度ならば、ガベージコレクション先生が気を利かしてくれるはずだ。

import io
import gc
import urllib.request
import zipfile

gc.enable()

URL = "http://localhost:8000/python-3.12.2-embed-amd64.zip"

with urllib.request.urlopen(URL) as f:
    content: bytes = f.read()

data = io.BytesIO(content)
with zipfile.ZipFile(data) as zf:
    names: list[str] = zf.namelist()

for name in names:
    print(name)

print(gc.is_tracked(data))
...
True

当然、 io.BytesIO(content) はガベージコレクタによって捕捉されており、必要に応じて回収されるだろう。

今後の課題

今回のスニペット程度ではガベージコレクタによって回収されるので、閉じ忘れても特に支障はないと思われる。 「ZIPファイルを手元で加工する」の内容如何では、ガベージコレクションによって回収されない状況が起き得るか、が自分の課題である。 また、 gc モジュールの使い方がイマイチわかっていないので、それの調査も課題である。