Python2からPython3に上げた際の日本語ファイルを含むZIPファイルの扱いについて

# はじめに
先日DjangoアプリケーションのPythonを2.7から3に上げるというタスクに携わったのですが、Python3化したソースをリリースした後、「**Python2の時にアップロードした日本語名のファイルをダウンロードできない**」というバグに直面しました。

アプリケーションの仕様では、アップロードされたファイルはZIP形式でストレージに保存され、ダウンロード時に`zipfile.extract()`にファイル名を渡して抽出するという挙動になっていたのですが、この`extract()`で`KeyError: "There is no item named 'xxx.yyy' in the archive"`というようなエラーが起きていました。

トラシューに際しては以下の記事が非常に参考になったのですが、これだけでは解決しない部分もあったため、補足という意味も含めて記事を書かせていただこうと思います。

[Python で zip 展開（日本語ファイル名対応） - Qiita](https://qiita.com/tohka383/items/b72970b295cbc4baf5ab)

# 原因１：zipfile の仕様変更
これは上で挙げた記事に書いてあることですが、Python2の`zipfile.extract()`ではファイル名がバイト列で返ってくるため文字コードを気にする必要がなかったのが、Python3ではZIPファイルのヘッダー情報を見て**UTF-8フラグが立っていない場合はファイル名がすべてCP437でデコードされてしまう**という仕様変更の問題があります。

......と言われても、ZIPの仕様やPythonの文字列の扱いについてよく知らない私のような人間はこれだけじゃいまいちピンときませんので、もう少し解像度を上げてみたいと思います。

## zipfile.extract() 概観
まず、Pythonの`zipfile.ZipFile`オブジェクトは格納しているファイルごとにメタ情報（`ZipInfo`オブジェクト）を保持しています。`zipfile.extract()`はこの`ZipInfo`に含まれるパスを利用して対象ファイルにアクセスしてデータの抽出を行います。

~~~zipfile.py
    def extract(self, member, path=None, pwd=None):
...
        if not isinstance(member, ZipInfo):
            member = self.getinfo(member)
...
        return self._extract_member(member, path, pwd)
~~~

ここで`zipfile.extract()`にファイル名が渡された場合、`ZipFile.getinfo()`が呼ばれ、`getinfo()`は`ZipFile`オブジェクトの`NameToInfo`属性を参照して対象ファイルの`ZipInfo`オブジェクトを取得します。`NameToInfo`属性は`{ファイル名: ZipInfo}`という辞書オブジェクトになっています。

~~~zipfile.py
class ZipFile(object):
...
    def __init__(self, file, mode="r", compression=ZIP_STORED, allowZip64=False):
...
        self.NameToInfo = {}    # Find file info given name
...
    def getinfo(self, name):
        """Return the instance of ZipInfo given 'name'."""
        info = self.NameToInfo.get(name)
        if info is None:
            raise KeyError(
                'There is no item named %r in the archive' % name)

        return info
~~~

この流れはPython2,3共通です。

## Python3での変更点
ではPython3になって何がどう変わったかというと、最初に説明したように、この`ZipInfo`や`NameToInfo`に含まれるファイル名をセットする時にファイル名を勝手にデコードするようになりました。より具体的には、`zipfile.ZipFile._RealGetContents()`の仕様変更です。

Python2では以下のようにUTF-8フラグが立っている時のみUTF-8でデコードを行い、それ以外はバイト列をそのままセットしています。

~~~zipfile.py
class ZipFile(object):
...
    def __init__(self, file, mode="r", compression=ZIP_STORED, allowZip64=False):
...
        try:
            if key == 'r':
                self._RealGetContents()
...
    def _RealGetContents(self):
        """Read in the table of contents for the ZIP file."""
...
            filename = fp.read(centdir[_CD_FILENAME_LENGTH])
            # Create ZipInfo instance to store file information
            x = ZipInfo(filename)
...
            x.filename = x._decodeFilename()
            self.filelist.append(x)
            self.NameToInfo[x.filename] = x
...
    def _decodeFilename(self):
        if self.flag_bits & 0x800:
            return self.filename.decode('utf-8')
        else:
            return self.filename
~~~

一方Python3の`_RealGetContents()`は以下のようにファイル名を**UTF-8かCP437の必ずどちらかでデコード**します。

~~~zipfile.py
    def _RealGetContents(self):
        """Read in the table of contents for the ZIP file."""
...
            filename = fp.read(centdir[_CD_FILENAME_LENGTH])
            flags = centdir[5]
            if flags & 0x800:
                # UTF-8 file names extension
                filename = filename.decode('utf-8')
            else:
                # Historical ZIP filename encoding
                filename = filename.decode('cp437')
~~~

この仕様変更により、Shift_JISなどZIPの仕様書に書かれていない文字コードでエンコードされたファイルはおろか、**UTF-8でエンコードされていてもUTF-8フラグが立っていないファイルまでCP437で強制デコードされる**ため、抽出したファイルの名前が文字化けしたりエラーが発生したりしてしまいます。

# 原因２：extract() にファイル名を渡している
実はZIPファイルから抽出するファイルを指定するには、`zipfile.extract()`の第１引数としてファイル名と`ZipInfo`オブジェクトのどちらを渡すこともできます。

`ZipInfo`オブジェクトを渡す場合には「はじめに」で紹介した記事の対処法（`ZipInfo.filename`をデコードし直し、修正した`ZipInfo`オブジェクトを`extract()`に渡す）で上手くいくのですが、ファイル名を渡す場合ではまだエラーが解消されません。具体例で確認しましょう。

`python_zip`というディレクトリにファイル名がShift_Jisでエンコードされた`テスト用です.txt`というファイルを作り、これを同じディレクトリに`test.zip`として圧縮します（LANGがUTF-8なので`ls`すると文字化けします）。また、抽出したファイルの格納先として`extracted`というディレクトリも作成しておきます。

~~~
~/python_zip$ ls
''$'\203''e'$'\203''X'$'\203''g'$'\227''p'$'\202''ł'$'\267''.txt'   extracted   test.zip
~~~

この`test.zip`から以下のように`ZipInfo.filename`を書き換えるコードでtxtファイルを抽出してみます。

~~~extract_zip_py3.py
import zipfile

zf = zipfile.ZipFile("test.zip", 'r')
for info in zf.infolist():
    bad_filename = info.filename
    info.filename = info.filename.encode('cp437').decode('shift_jis')
    zf.extract("テスト用です.txt", "./extracted")
zf.close()
~~~

以下のように`KeyError`となってしまいます。

~~~
~/python_zip$ python extract_zip_py3.py
Traceback (most recent call last):
  File "extract_zip_py3.py", line 24, in <module>
    zf.extract("テスト用です.txt", "./extracted")
（中略）
KeyError: "There is no item named 'テスト用です.txt' in the archive"
~~~

「zipfile.extract() 概観」で述べたように、ファイル名で指定する場合は`NameToInfo`属性を参照するのでした。`test.zip`の`NameToInfo`をデバッグすると、`ZipInfo.filename`を書き換えた後では以下のようになっています。

~~~
{'âeâXâgùpé┼é╖.txt': <ZipInfo filename='テスト用です.txt' filemode='-rw-r--r--' file_size=19>}
~~~

確かに`filename`は直せていますが、キーが依然として文字化けしたファイル名になっているため`getinfo()`内の`self.NameToInfo.get(name)`でマッチできずエラーになってしまいます。

# 対処法１
ということは、この場合は更に`NameToInfo`も書き換えてしまえば上手くいくということです。先程の`extract_zip_py3.py`を以下のように修正します。

~~~extract_zip_py3.py
import zipfile

zf = zipfile.ZipFile("test.zip", 'r')
for info in zf.infolist():
    bad_filename = info.filename
    info.filename = info.filename.encode('cp437').decode('shift_jis')
    zf.NameToInfo[info.filename] = info
    del zf.NameToInfo[bad_filename]
print(zf.NameToInfo) # デバッグ用
zf.extract("テスト用です.txt", "./extracted")
zf.close()
~~~

これを実行すると以下のようになります。

~~~
~/python_zip$ python extract_zip_py3.py
{'テスト用です.txt': <ZipInfo filename='テスト用です.txt' filemode='-rw-r--r--' file_size=19>}
~/python_zip$ ls extracted
テスト用です.txt
~~~

`NameToInfo`が書き換えられたことで対象ファイルの`ZipInfo`が正しく取得でき、文字化けせずにファイルの抽出ができていることが確認できます。

# 対処法２（+エンコーディング判定）
DjangoアプリケーションのPython2 to 3の対応としては基本的には対処法１で十分だと思いますが、万が一のことを考えるとデコードし直す文字コードは決め打ちよりも自動で判定できた方が安全度が高いと言えます。

そこで、先程のShift_Jisの`テスト用です.txt`に加えてファイル名がUTF-8の`テスト用２です.txt`を作成してtest2.zipに圧縮しました。

~~~
~/python_zip$ ls
''$'\203''e'$'\203''X'$'\203''g'$'\227''p'$'\202''ł'$'\267''.txt'   extracted    test2.zip
extract_zip_py3.py                                                  test.zip     テスト用２です.txt
~~~

そして`extract_zip_py3.py`を以下のように修正しました。

~~~extract_zip_py3.py
import sys
import zipfile
import chardet

args = sys.argv
zname = args[1] # ZIPファイル名
fname = args[2] # 抽出対象ファイル名

zf = zipfile.ZipFile(zname, 'r')
for info in zf.infolist():
    bad_filename = info.filename
    code = chardet.detect(info.filename.encode('cp437')) # 文字コード自動判定
    print(code)
    info.filename = info.filename.encode('cp437').decode(code['encoding'])
    zf.NameToInfo[info.filename] = info
    del zf.NameToInfo[bad_filename]
print(zf.NameToInfo)
zf.extract(fname, "./extracted")
zf.close()
~~~

こちらを実行してみます。

~~~
~/python_zip$ python extract_zip_py3.py test.zip テスト用です.txt
{'encoding': 'SHIFT_JIS', 'confidence': 0.99, 'language': 'Japanese'}
{'テスト用です.txt': <ZipInfo filename='テスト用です.txt' filemode='-rw-r--r--' file_size=19>}

~/python_zip$ python extract_zip_py3.py test2.zip テスト用２です.txt
{'encoding': 'utf-8', 'confidence': 0.99, 'language': ''}
{'テスト用２です.txt': <ZipInfo filename='テスト用２です.txt' filemode='-rw-r--r--' file_size=28>}

~/python_zip$ ls extracted
テスト用です.txt  テスト用２です.txt
~~~

意図したとおり、自動判定によって導出された文字コードでファイル名をデコードし直し、文字化けすることなくファイルの抽出を行うことができました。

# 一旦まとめ
今回はPythonの文字列の扱いにおける仕様変更と`zipfile`モジュールの仕様変更が重なっていて、自分なりに納得の行く説明ができるようになるまで比較的時間がかかりました。またPython2 to 3 関連で何か書きたいですね。

ひとまずここまでご覧いただきありがとうございました。ここから先は執筆中に気になった細々としたことについて書いていきますので、興味のある方はもう少しお付き合いください。

# 疑問①：抽出するファイルのパスはどこにある？
今回の対処法では`ZipFile`オブジェクトが持っているメタ情報を修正しているわけですが、`filename`や`NameToInfo`を勝手に書き換えてもZIPアーカイブの対象ファイルを見つけ出せるというのがよくよく考えると不思議だぞと思ったんですね。

そこでソースをもう少し追いかけたら、`ZipInfo.orig_filename`という属性値が別に用意されていて、これとZIPアーカイブのローカルヘッダ（ZIPに格納されているファイルごとのメタデータ。下図参照）に書かれているファイル名（をデコードした文字列）を比較して一致したらそのファイルを`open()`するという仕様になっていました。

先程の`extract_zip_py3.py`をハードコーディングでデバッグすると、`NameToInfo`を書き換えた後でも`orig_filename`は文字化けしたままで、ローカルヘッダから取得したファイル名（`fname`）を`CP437`でデコードした文字列と一致しました。

~~~
~/python_zip$ python extract_zip_py3.py test.zip テスト用です.txt
{'テスト用です.txt': <ZipInfo filename='テスト用です.txt' filemode='-rw-r--r--' file_size=19>}
orig_filename: âeâXâgùpé┼é╖.txt
fname: b'\x83e\x83X\x83g\x97p\x82\xc5\x82\xb7.txt'

~/python_zip$ python
Python 3.7.5 (default, Nov 22 2020, 16:16:44)
[GCC 7.5.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> s = b'\x83e\x83X\x83g\x97p\x82\xc5\x82\xb7.txt'
>>> s.decode('cp437')
'âeâXâgùpé┼é╖.txt'
~~~

ファイルを探すための情報は`orig_filename`とZIPのローカルヘッダに書かれているため影響を受けず、`filename`は抽出したファイルの名前の方に利用されるので問題ないということですね。上手いことできています。

![ZIPformat ja - ZIP (ファイルフォーマット) - Wikipedia](https://upload.wikimedia.org/wikipedia/commons/5/5e/ZIPformat_ja.jpg)

# 疑問②：UTF-8でエンコードされているのにフラグが立っていないのはなぜか？
今回記事を書くきっかけとなったバグは、もとを正せばPython2の時にUTF-8でエンコードされていたZIPアーカイブ内のファイルにUTF-8フラグが立っていなかったせいで起こったものでした。なぜそのようなことになったのでしょうか？

Python2の`zipfile`モジュールのソースを見ると、ファイル名が`Unicode`型且つ`ACSII`でエンコードできなかった場合にのみUTF-8フラグを立てていることがわかりました。

~~~zipfile.py
class ZipInfo (object):
...
    def FileHeader(self, zip64=None):
...
        filename, flag_bits = self._encodeFilenameFlags()
...
    def _encodeFilenameFlags(self):
        if isinstance(self.filename, unicode):
            try:
                return self.filename.encode('ascii'), self.flag_bits
            except UnicodeEncodeError:
                return self.filename.encode('utf-8'), self.flag_bits | 0x800
        else:
            return self.filename, self.flag_bits
~~~

そこで、以下のようにファイル名がUnicode文字列のファイルを作成してZIPに圧縮しファイル抽出を行うPython2プログラムにデバッグコードを入れて実行してみました。

~~~zip_py2.py
# -*- coding: utf-8 -*-

import zipfile

# ファイル作成、ZIPに格納、別ディレクトリに抽出
with open(u"テスト用です.txt", 'w') as f:
    f.write("ファイルに書き込み\n")

zf = zipfile.ZipFile("test.zip", 'w')
zf.write(u"テスト用です.txt")
zf.close()

zf = zipfile.ZipFile("test.zip", 'r')
print zf.NameToInfo
print zf.infolist()[0].flag_bits
zf.extract(u"テスト用です.txt", "./extracted")
zf.close()
~~~

以下がその実行結果になります。`0x800`は10進数では`2048`なので、確かにUTF-8が立っています。

~~~
~/python_zip$ python --version
Python 2.7.17

~/python_zip$ python zip_py2.py
{u'\u30c6\u30b9\u30c8\u7528\u3067\u3059.txt': <zipfile.ZipInfo object at 0x7f68ec012940>}
2048

~/python_zip$ ls extracted
テスト用です.txt
~~~

めでたし。

# 参考文献
[cpython/zipfile.py at 2.7 · python/cpython · GitHub](https://github.com/python/cpython/blob/2.7/Lib/zipfile.py#L1139)
[cpython/zipfile.py at 3.7 · python/cpython · GitHub](https://github.com/python/cpython/blob/3.7/Lib/zipfile.py)
[.ZIP ファイルフォーマット仕様書（英語）](https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT)
[ZIPの仕様を日本語でまとめる · GitHub](https://gist.github.com/ysakasin/2edf8d3bf55c6ebf63f82851e302b030)：↑の日本語訳です。ありがたい。
[ZIP (ファイルフォーマット) - Wikipedia](https://ja.wikipedia.org/wiki/ZIP_(%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%83%95%E3%82%A9%E3%83%BC%E3%83%9E%E3%83%83%E3%83%88)#%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%83%98%E3%83%83%E3%83%80)
[zipfile でのファイル名の扱いもまともになってしまったっぽい - つちのこ、のこのこ。(beta)](https://mitsuru.hateblo.jp/entry/20081207/zipfile)