黒魔術で Windows 上の Python3 が UnicodeDecodeError を吐かないように理解らせる

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

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

この記事 いわく、環境変数に PYTHONUTF8=1 を登録すればデフォルトで UTF-8 を使ってくれるようになるそうですが、さすがにユーザーに環境変数いじれとは言いづらいし、そもそもプログラム側では制御できません(重要)
python -X utf-8 (ファイル名) のように -X utf-8 をつけて実行しても UTF-8 モードになるようですが、こちらもプログラム側では UTF-8 モードにできないので却下…
最悪ユーザーに向けて「このスクリプトを実行するときは必ず UTF-8 モードで実行すること」と書いておくことも可能でしょうけど、本当に最後の手段なのでできるだけ避けたいところです。

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

Shift-JIS大好き!!!!!!!とか抜かす輩が目の前にいたら一発ブン殴りたいレベルで年々 Shift-JIS に対する呪詛が増している…
Sponsored Link

どうしてこうなった

open() 関数の encoding はオプション引数になっているので、何も指定しなかった場合、「macOS・Linux など標準でシステムロケールが UTF-8 になっている OS であれば」読み込むファイルが UTF-8 であると仮定して読み込まれます。
ところが Windows 環境ではコードページ(笑)がデフォルトで Shift-JIS (CP932)(英語圏であれば Windows-1252 (CP1252))になっているので、encoding 引数に 'utf-8' と指定してあげないと UTF-8 のテキストを Shift-JIS で読み込めるわけもなく、デコードに失敗して 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 になる件についてでしたが、python-dotenv に関しては こちら のプルリクがマージされた事で 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 地獄から解放されそう……。。

コードを読んでいて(もしや sys.flags.utf8_mode を True にすればプログラムから UTF-8 mode で実行できるのでは)と思ったりもしましたが、そううまく行くはずもなく sys ライブラリが C 言語で実装されているからか readonly 扱いで Python 側からは変更できず… 残念…
Sponsored Link
Sponsored Link
Web
tsukumiをフォローする
Sponsored Link
つくみ島だより

コメント