Windows 上の Python (Python3) で open()
関数を使ったときに出る UnicodeDecodeError (ex: UnicodeDecodeError: 'cp932' codec can't decode byte 0x** in position **: illegal multibyte sequence
) といえば、Python が標準で悪名高い全ての元凶害悪ゴミ産廃 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 モードで実行すること」と書いておくことも可能でしょうけど、本当に最後の手段なのでできるだけ避けたいところです。
こちらの記事 では散々試したけど結局変えられなかったそうで、すでに絶望のドン底…
どうしてこうなった
open() 関数の encoding
はオプション引数になっているので、何も指定しなかった場合、「macOS・Linux など標準でシステムロケールが UTF-8 になっている OS であれば」読み込むファイルが UTF-8 であると仮定して読み込まれます。
ところが Windows 環境ではコードページ(笑)がデフォルトで Shift-JIS (CP932)(英語圏であれば Windows-1252 (CP1252))になっているので、encoding
引数に 'utf-8'
と指定してあげないと UTF-8 のテキストを Shift-JIS で読み込めるわけもなく、デコードに失敗して UnicodeDecodeError を吐いて落ちます。 素直に死んでほしい。
locale.getpreferredencoding()
の返す値を使うようになっているらしい(encoding 引数をオプションにするのならどのプラットフォームでも UTF-8 で読み込んでおくれ…)つまりは open()
関数に食わせるファイルが UTF-8 であるという前提でプログラムを組むなら Windows 環境でも UTF-8 で読み込まれるように encoding='utf-8'
を必ず追加しないといけないわけですが、英語圏だと ASCII 範囲の文字しか使わなくて UTF-8 と Windows-1252 で文字コードが別でもさほどエラー吐かなかったりするからなのか、それをやり忘れているライブラリの多いこと多いこと…
逆に読み込むファイルを Shift-JIS にすれば解決するのはそうですが、絵文字使えないし逆に他の OS で読み込めなくなるのでできるだけやりたくありません。
ライブラリ自体のコードを直接いじるのは気が引けるし…
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
している場合は不要)。二重実行されるとそれはそれでエラーが出る気がするので、そこは適宜工夫する必要があるかも(?)
動作原理
私もよくわかっていませんが、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 側からは変更できず… 残念…
コメント