Braindump

Python でテキストファイル読み込み時に注意すること

July 31, 2023
python

案件などでお客様の使用しているテキストファイル (CSV, YAML, TXT, etc…) を読み込んでゴニョゴニョする処理を書くときがある。 これらのテキストファイルがどこでどのように作られたかは分からない。

システムの文字コードとファイルの文字コードが異なる場合、ファイル読み込みに失敗する。 また、ファイルが暗号化されていても読み込みに失敗する。

そのため、ファイル読み込み処理は上記のことを考慮して実装する必要がある。 諸々考慮して実装した、ぼくのかんがえたさいきょうのコードが下記。

def load_file(path_to_file):
    encodings = ["utf-8-sig", "cp932", "shift_jis", "euc-jp", "iso-2022-jp"]
    for encoding in encodings:
        try:
            with open(path_to_file, "r", encoding=encoding) as f:
                # 何らかの方法でファイル読み込み
                # yaml の場合
                loaded_file = yaml.safe_load(f)
            # いろいろして、何かを return する
            return

        # 指定した encoding で読み込みに失敗したとき
        except UnicodeDecodeError as e:
            # 別の encoding を試すために continue で次のループへ進む
            # 失敗したという情報を残しておいてもいいかもしれない
            logger.debug(e)
            continue

        # その他もろもろ想定され得るエラーを拾う
        except ...
            ...

        # 想定外のエラーを忘れずに拾う
        except Exception as e:
            # いろいろ処理したりメッセージ出したり終了させたり
            ...
    else:
        # encodings の文字コードで読み込みに失敗したということは
        # ファイルが暗号化されていて失敗した可能性があるため、
        # その旨をメッセージとして出力してあげると親切
        logger.error("ファイル読み込みに失敗しました。")
        logger.error("ファイルが暗号化されていないか確認してください。")
        # 最後に何か処理とか終了とか

上から順に解説していく。

考慮する文字コードの種類 #

encodings = ["utf-8-sig", "cp932", "shift_jis", "euc-jp", "iso-2022-jp"]

日本語で使用され得る文字コードを列挙しておけば問題無いと考えている。

utf-8 ではなく utf-8-sig を使用する理由 #

utf-8-sig は BOM 付き UTF-8 に対応した文字コードである。

BOM (byte order mark) とは、Unicodeの符号化形式で符号化したテキストの先頭につける数バイトのデータのことである (参考: wiki)。 文字コードに utf-8-sig を指定することで、テキストの先頭に付いた BOM を除去して読み込んでくれる。

BOM 付き UTF-8 のファイルを文字コード utf-8 で読み込むこともできる。 しかし、テキスト先頭に BOM が残ってしまうため、例えば CSV の列名を指定して処理する際に文字列が一致しなくてバグる可能性がある。

一方、BOM の無い UTF-8 のファイルを文字コード utf-8-sig で読み込んだ場合、先頭に BOM が無いためテキストはそのまま読み込まれる。 (参考: encodings.utf_8_sig — UTF-8 codec with BOM signature)

したがって、UTF-8 を想定した文字コードには utf-8-sig を指定しておけば安全だと言える。

ファイル読み込みとエラーハンドリング #

ファイル読み込み #

for encoding in encodings:
    try:
        with open(path_to_file, "r", encoding=encoding) as f:
            # 何らかの方法でファイル読み込み
            # yaml の場合
            loaded_file = yaml.safe_load(f)
        # いろいろして、何かを return する
        return

用意した文字コードを順次試しつつファイルを読み込んでいく。

with open でファイルを開くときは encoding に文字コードを指定する。

CSVファイルの読み込みに pandas.read_csv を使用する場合は with open を使用せずに1行で書くことができる。

loaded_file = pandas.read_csv(path_to_file, encoding=encoding)

エンコードエラーへの対処 #

# 指定した encoding で読み込みに失敗したとき
except UnicodeDecodeError as e:
    # 別の encoding を試すために continue で次のループへ進む
    # 失敗したという情報を残しておいてもいいかもしれない
    logger.debug(e)
    continue

エンコードエラーが発生したときは UnicodeDecodeError が送出される。 このときは continue で次のループへと進めば良い。

念のため、エンコードエラーが発生したことをログに残しても良い。

それ以外のエラーへの対処 #

# その他もろもろ想定され得るエラーを拾う
except ...
    ...

# 想定外のエラーを忘れずに拾う
except Exception as e:
    # いろいろ処理したりメッセージ出したり終了させたり
    ...

文字コード以外のエラーを忘れずに拾う。 例えば yaml ファイルの場合、構文エラーなどが起こり得る。

最後に想定外のエラーを Exception で忘れずに拾う。 Exception ブロックを書かなかった場合、Python は長々とエラーを出力して落ちる。

個人で使うプログラムなら自分で解釈できるのでそれで良いかもしれない。 今回はお客様が使用する前提があるため、お客様がどうすれば良いか分かるようにメッセージを出してあげたほうが親切。 想定外エラーのときの処理について取り決めがあれば、その処理を記載する。

ファイル暗号化への対処 #

else:
    # encodings の文字コードで読み込みに失敗したということは
    # ファイルが暗号化されていて失敗した可能性があるため、
    # その旨をメッセージとして出力してあげると親切
    logger.error("ファイル読み込みに失敗しました。")
    logger.error("ファイルが暗号化されていないか確認してください。")
    # 最後に何か処理とか終了とか

for-loop が最後まで回って次に進んだとき、それは想定した全ての文字コードでのファイル読み込みに失敗したことを意味する。 この場合、ファイルが暗号化されている可能性が非常に高いと考えられる。

なぜなら、暗号化されているファイルを読み込んだときもエンコードエラー UnicodeDecodeError が送出されるからである。 暗号化に起因するエラーは UnicodeDecodeError であるため、 Exception でキャッチすることができず最後まで素通りしてしてしまう。 そのため、for-loop の後に対処する必要がある。

上記の例ではファイルが暗号化されていないかお客様への確認を促している。 「ファイルは暗号化されていないこと」という取り決めが事前にあったとしても、相手は人間なのでミスは起きる。 念のためメッセージを仕込んでおいた方が良いと思われる。

まとめ #

最後に、ぼくのかんがえたさいきょうのコードを再掲して終わる。

def load_file(path_to_file):
    encodings = ["utf-8-sig", "cp932", "shift_jis", "euc-jp", "iso-2022-jp"]
    for encoding in encodings:
        try:
            with open(path_to_file, "r", encoding=encoding) as f:
                # 何らかの方法でファイル読み込み
                # yaml の場合
                loaded_file = yaml.safe_load(f)
            # いろいろして、何かを return する
            return

        # 指定した encoding で読み込みに失敗したとき
        except UnicodeDecodeError as e:
            # 別の encoding を試すために continue で次のループへ進む
            # 失敗したという情報を残しておいてもいいかもしれない
            logger.debug(e)
            continue

        # その他もろもろ想定され得るエラーを拾う
        except ...
            ...

        # 想定外のエラーを忘れずに拾う
        except Exception as e:
            # いろいろ処理したりメッセージ出したり終了させたり
            ...
    else:
        # encodings の文字コードで読み込みに失敗したということは
        # ファイルが暗号化されていて失敗した可能性があるため、
        # その旨をメッセージとして出力してあげると親切
        logger.error("ファイル読み込みに失敗しました。")
        logger.error("ファイルが暗号化されていないか確認してください。")
        # 最後に何か処理とか終了とか