【Windows】黒魔術で Python が CP932 関係で UnicodeDecodeError を出さないように強制する

Windows 上の Python (Python3) で open() 関数を使ったときに出る UnicodeDecodeError (ex: UnicodeDecodeError: 'cp932' codec can't decode byte 0x** in position **: illegal multibyte sequence) といえば、Python が標準で悪名高い全ての元凶害悪ゴミ産廃 Shift-JIS (CP932) としてファイルを読み込んでしまうことが原因であることはそれなりに知られているかと思います。

このエラー、自分で書いたコードなら全ての open() 関数の引数に encoding='utf-8' を追加してあげれば回避できますが、使おうとしたライブラリからそのエラーが出る場合はこちら側から制御できないので絶望するしか…

この記事 いわく、環境変数に PYTHONUTF8=1 を登録すればデフォルトで UTF-8 を使ってくれるようになるそうですが、さすがにユーザーに環境変数いじれとは言いづらいです。
さらに、そもそも Python コード側から open() 関数を UTF-8 で開くように強制することはできません。

python -X utf-8 (ファイル名) のように -X utf-8 をつけて実行しても UTF-8 モードになるようですが、こちらもプログラム側では UTF-8 モードにできないので却下。
最悪ユーザーに向けて「このスクリプトを実行するときは必ず UTF-8 モードで実行すること」と書いておくことも可能でしょうけど、煩わしいしできるだけ避けたいところです。

こちらの記事 では散々試したけど結局変えられなかったそうで、すでに絶望…

Sponsored Link

どうしてこうなった

open() 関数の encoding はオプション引数になっているので、何も指定しなかった場合、macOS・Linux など標準でシステムロケールが UTF-8 になっている OS であれば」読み込むファイルが UTF-8 であると仮定して読み込まれます。

ところが Windows 環境ではコードページがデフォルトで Shift-JIS こと CP932になっているため(ちなみに英語圏では CP1252 )、UTF-8 のテキストを Shift-JIS で読み込めるわけもなく、encoding 引数に 'utf-8' と明示的に文字コードを指定してあげない限り、文字のデコードに失敗して UnicodeDecodeError を出して落ちてしまいます。軽く死んでほしい。

Python のドキュメントいわく、正確には locale.getpreferredencoding() の返す値を使うようになっているらしい(encoding 引数をオプションにするのならどのプラットフォームでも UTF-8 で読み込んでおくれ…)

つまりは open() 関数に食わせるファイルが UTF-8 であるという前提でプログラムを組むなら、Windows 環境でも UTF-8 で読み込まれるように encoding='utf-8' を必ず追加しないといけません。
…が、英語圏だと ASCII 範囲の文字しか使わなくて UTF-8 と Windows-1252 で文字コードが別でもさほどエラー吐かなかったりするからなのか、それをやり忘れているライブラリの多いこと多いこと…😡😡

逆に読み込むファイルの文字コードを Shift-JIS にすれば一応解消はしますが、絵文字使えないし逆に他の OS で読み込めなくなるのでできるだけ避けたいところです。とはいえライブラリ自体のコードを直接いじるのも気が引けるし…。

今回実際に詰まったのは python-dotenv の load_dotenv() 関数に日本語のコメントを書いた UTF-8 の .env ファイルを食わせると python-dotenv 側が CP932 で読み込もうとしてしまい UnicodeDecodeError になる問題でしたが、これに関しては こちら のプルリクがマージされた事で、 load_dotenv() 関数に encoding=’utf-8′ を指定してあげれば UTF-8 で読み込めるようになりました(.env に日本語のコメントを書かなければいいというのはそう)。

強引にハックする

で、お待ちかねの黒魔術強引にハックする方法がこちらになります。
ネットの海を漁ってたらたまたま見つけた StackOverflow の質問 に対する こちらの回答 が大変参考になりました。

# Windows 環境向けのハック
# 参考: https://stackoverflow.com/questions/31469707/changing-the-locale-preferred-encoding-in-python-3-in-windows
import os
if os.name == 'nt':
    import _locale
    _locale._getdefaultlocale_backup = _locale._getdefaultlocale
    _locale._getdefaultlocale = (lambda *args: (_locale._getdefaultlocale_backup()[0], 'UTF-8'))

以上のコードを、UnicodeDecodeError が発生するライブラリを呼び出すコードよりも前にコピペします。
するとこのコード以降の処理のデフォルト文字コードが UTF-8 に固定されるため、どのような場合でも 'cp932' codec can't decode byte … というエラーが出なくなり、ファイルを正しく UTF-8 で読み込めるようになるはずです。

このハックより後に記述されたコードで直接、または間接的に呼び出される locale.getdefaultlocale() 関数の挙動そのものを変更するもので、事実上 Python を UTF-8 モードで起動しているのとほぼ同じ状態になります。

if os.name == 'nt': では OS が Windows かどうかを判定しています。os モジュールを利用するため、import os も必須です(それより前のコードで import している場合は不要)。
こういう無理やり書き換える系のコードをモンキーパッチと呼ぶらしい。二重実行されるとそれはそれでエラーが出る気がするので、そこは適宜工夫する必要があるかも(?)
UTF-8 モードの実装経緯は こちら に詳しく載っていました。後方互換性…しんどい…
StackOverflow の内容を日本語に機械翻訳しただけの低質なサイトは複数あるけど、日本語で検索してたらそれらのサイトのページが引っかかって結果的に有益な情報に巡りつけて助かったという事が過去 10 回くらいあるのであまり憎めかったり。

動作原理

私もよくわかっていませんが、Python の標準ライブラリである locale(Python製)が内部的に利用するネイティブの隠し標準ライブラリ(C言語製)、_locale が存在します。
そこで、_locale 内の関数のうち、 _getdefaultlocale() 関数(標準のロケール設定を取得しようと試み、結果をタプル (language code, encoding) の形式で返す)を、あらかじめバックアップしたネイティブの _getdefaultlocale_backup() 関数が返す国コードと UTF-8 に固定した文字コードのタプルで返す関数として無理やり上書きし、その後のコードで実行される locale.getdefaultlocale() 関数の文字コードの返り値を無理やり UTF-8 に固定することで実現しているものと思われます。超荒業…。

先ほど述べた通り、 open() 関数の内部では encoding 引数がなかった場合は locale.getpreferredencoding() の返す値を使うようになっています。そして、locale.getpreferredencoding() は OS が Windows の場合、_bootlocale という隠し標準ライブラリ内の _bootlocale.getpreferredencoding() に処理を投げます。
さらに _bootlocale.getpreferredencoding() は OS が Windows の場合、先ほど上書きした locale._getdefaultlocale() が返すタプルの 1 番目の値(文字コード)を利用するようになっているため、巡り巡って open() 関数に encoding 引数を指定しなかった場合、Windows でもデフォルトで UTF-8 で開くようにできる、といった具合らしいです。

かなり無理やりな方法ではありますが、macOS や Linux 上の Python の locale.getdefaultlocale() が返す文字コードは基本的に 'UTF-8' なので、さほど問題にはならないんじゃないかなーと思っています。
このハック(モンキーパッチ)を実行するファイルの一番上に追記するという手間は残りますが、この方法なら UnicodeDecodeError 地獄をなんとかできそうです。

Python のコードを読んでいて(もしや sys.flags.utf8_mode を True にすればプログラムから UTF-8 mode で実行できるのでは)と思ったりもしました。…がそううまく行くはずもなく、sys ライブラリが C 言語で実装されているからかプロパティ自体 readonly 扱いで Python 側からは変更できませんでした。残念。

コメント