Python 3の各種エンコーディングについて

Python 2 に比べるとずっと楽になったものの、環境によっては Python 3 で予期せぬ UnicodeError に遭遇することがあります。 Python 3.6 時点での、 Python の各種エンコーディングの扱いを整理してみます。

## Python のエンコーディング
### filesystem encoding (``sys.getfilesystemencoding()``)

主にファイルパスに使うエンコーディングですが、コマンドライン引数にも使われます。 (そうでないとファイルパスをコマンドライン引数に渡したときに困る)

また locale が関連するので、実際にはそれ以外にも glibc とかと連携するときに使われます。 Python 2 時代の名残りでしょうが、今では filesystem encoding というより system encoding と呼んだほうが実態を表している気がします。

### preferred encoding (``locale.getpreferredencoding()``)

主にテキストファイルの内容に使われるエンコーディングです。 `open` 関数でテキストファイルを開くときなどに使われます。

### 標準入出力のエンコーディング (``sys.stdout.encoding``)

標準入出力のエンコーディングは、端末なら filesystem encoding、それ以外では preferred encoding なのですが、 `PYTHONIOENCODING` という環境変数で変更できます。

### default encoding (``sys.getdefaultencoding()``)

unicode 文字列 (``str``) とバイト列 (``bytes``) 間で変換するときに、明示的にエンコーディングを指定しないときに使われるデフォルトのエンコーディングです。Python 3 では完全に、環境に関わらず、 UTF-8 固定です。

```
>>> "こんにちは".encode()
b'\xe3\x81\x93\xe3\x82\x93\xe3\x81\xab\xe3\x81\xa1\xe3\x81\xaf'
>>> _.decode()
'こんにちは'
```

Python 2 時代には ascii だったのと、暗黙の型変換があったので、トラブルの種になりがちでした。 (マルチバイトを考えてくれてないアプリを無理やり utf-8 で動かすためのハックなどもありました)

Python 3 では str と bytes の間で暗黙の型変換がされないので、単にデフォルト引数程度の意味しかないです。存在自体忘れても構いません。


## 各種環境での振る舞い

### macOS, android

OSレベルで UTF-8 固定になっているので、 filesystem encoding は常に UTF-8 になります。

preferred encoding は、 Android は常に UTF-8 固定、 macOS であれば locale で変更できるものの、滅多に問題になることはないと思います。 (locale を設定する場合は、後述する Linux と同じになります)


### Windows

Windows はそもそもファイルを開くときもコマンドライン引数を受け取るときもW系APIを使うので、 filesystem encoding の出番は少ないです。

あえてファイルパスをバイト列で扱う場合の挙動が、 Python 3.5 までは Windows の A系API呼び出しだったので filesystem encoding は現在のコードページ `'cp932'` とかでした。
Python 3.6 からはこの動作がかわり、 UTF-8 => UTF-16 変換して W 系APIを使うようになったので、 filesystem encoding は `'utf-8'` です。(Python 3.5 の挙動に戻すための環境変数もあります)

一方、 preferred encoding は、今でもコードページを使います。 Microsoft が標準のテキストファイルのエンコーディングを UTF-8 にしてくれるまではずっとレガシーを引きずります。残念です。


### Linux, その他 Unix

一番残念なのがその他の Unix です。 locale (``LC_CTYPE``)依存です。今でも `LANG=ja_JP.eucJP` とかしてる人が居たら、 filesystem encoding も preferred encoding も EUC-JP になります。 UTF-8 でテキストファイルを作りたかったら、例え Windows に対応する気が全く無くても、 locale が UTF-8 じゃない環境のために `open` 関数では明示的にエンコーディングを指定しましょう。

locale 依存が特に残念なのは、 locale のデフォルト (C とか POSIX) のエンコーディング (LC_CTYPE) が規格上 ASCII ということになってるからです。

sort とかいろんなコマンドが locale によって挙動が変わるのが嫌で C や POSIX locale を使ってる人もいます。組み込みとかコンテナのイメージでは軽量化のために en_US.utf8 や ja_JP.utf8 ロケールが無いこともあります。また、 mac から ssh したら (環境変数 LANG が送られるので) en_US.UTF-8 しかない Linux で ja_JP.UTF-8 を使おうとしてエラーになって C にフォールバックするなんてこともあります。そんなとき、 Python は完全に ASCII モードになってしまいます。

```
$ export LC_ALL=C
$ echo 'print("こんにちは\n")' > hello
$ ruby hello
こんにちは
$ perl hello
こんにちは
$ python3 hello
Traceback (most recent call last):
  File "hello", line 1, in <module>
    print("\u3053\u3093\u306b\u3061\u306f\n")
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-4: ordinal not in range(128)
```

filesystem encoding が ASCII のままでも良いときは、locale を設定しなくても PYTHONIOENCODING で標準入出力のエンコーディングを指定できます。 .bashrc とか crontab に書いておきましょう。

```
$ PYTHONIOENCODING=utf-8 python3 hello
こんにちは
```

## (付録) Python で UTF-8 を使うための Linux の locale 設定

まず覚えてほしいのが、現在のロケールを表示する `locale` コマンドと、利用可能なロケールの一覧を表示する `locale -a` コマンドです。

```
$ locale
LANG=C.UTF-8
LANGUAGE=en_US:en
LC_CTYPE="C.UTF-8"
LC_NUMERIC="C.UTF-8"
LC_TIME="C.UTF-8"
LC_COLLATE="C.UTF-8"
LC_MONETARY="C.UTF-8"
LC_MESSAGES="C.UTF-8"
LC_PAPER="C.UTF-8"
LC_NAME="C.UTF-8"
LC_ADDRESS="C.UTF-8"
LC_TELEPHONE="C.UTF-8"
LC_MEASUREMENT="C.UTF-8"
LC_IDENTIFICATION="C.UTF-8"
LC_ALL=

$ locale -a
C
C.UTF-8
POSIX
en_US.utf8
```

`C.UTF-8` は、モダンな Linux なら搭載されていると思います。余計なことを一切しない C ロケールの UTF-8 版です。
C ロケールが使いたい、だけど UTF-8 ファイル名が使いたい、という人にピッタリです。存在しない場合、 root 権限があるなら、 `sudo localedef -c -i POSIX -f UTF-8 C.UTF-8` で作れるはずです。

ja_JP.UTF-8 や en_US.UTF-8 が存在していて、それを利用すれば良いなら、 LANG 環境変数にそれを設定して locale コマンドで確認してください。

```
$ export LANG=en_US.utf8
$ locale
LANG=en_US.utf8
LANGUAGE=en_US:en
LC_CTYPE="en_US.utf8"
LC_NUMERIC="en_US.utf8"
LC_TIME="en_US.utf8"
LC_COLLATE="en_US.utf8"
LC_MONETARY="en_US.utf8"
LC_MESSAGES="en_US.utf8"
LC_PAPER="en_US.utf8"
LC_NAME="en_US.utf8"
LC_ADDRESS="en_US.utf8"
LC_TELEPHONE="en_US.utf8"
LC_MEASUREMENT="en_US.utf8"
LC_IDENTIFICATION="en_US.utf8"
LC_ALL=
```

Cロケールが使いたい！でも UTF-8 が使いたい！でも C.UTF-8 が作れない！というときは、もうちょっと精密に制御しましょう。
各 LC_XXXXX の中で、 Python がエンコーディングを決めるのに利用しているのは `LC_CTYPE` です。
環境変数 `LANG` は `LC_ALL` 以外全体の LC_XXXX を設定し、それを各 LC_XXXX で個別に上書きできるけど、 `LC_ALL` が設定されているとさらにすべてを上書きします。
なので、 LANG=C にしつつ、 LC_CTYPE に何か UTF-8 のロケールを設定すれば良いのです。

```
$ export LANG=C
$ export LC_CTYPE=en_US.UTF-8
$ locale
LANG=C
LANGUAGE=en_US:en
LC_CTYPE=en_US.UTF-8
LC_NUMERIC="C"
LC_TIME="C"
LC_COLLATE="C"
LC_MONETARY="C"
LC_MESSAGES="C"
LC_PAPER="C"
LC_NAME="C"
LC_ADDRESS="C"
LC_TELEPHONE="C"
LC_MEASUREMENT="C"
LC_IDENTIFICATION="C"
LC_ALL=

$ locale charmap
UTF-8
$ python3 -c 'import sys; print(sys.getfilesystemencoding())'
utf-8
```