kvファイルに日本語が混ざるとUnicodeDecodeError




# 想定環境

- Windows:日本語版
- Python:3.6.5
- kivy:1.10.0



# 現象

Windowsでデフォルト設定から変更せず、kvファイルを書く・もらってくる・コピペするなどし、kvファイルに日本語が混ざった場合、実行すると以下のエラーが表示されます。

```sh
 Traceback (most recent call last):
   File "main.py", line 22, in <module>
     mw.run()
   File "E:\app\create\Python365\lib\site-packages\kivy\app.py", line 801, in run
     self.load_kv(filename=self.kv_file)
   File "E:\app\create\Python365\lib\site-packages\kivy\app.py", line 598, in load_kv
     root = Builder.load_file(rfilename)
   File "E:\app\create\Python365\lib\site-packages\kivy\lang\builder.py", line 290, in load_file
     data = fd.read()
 UnicodeDecodeError: 'cp932' codec can't decode byte 0x83 in position 38: illegal multibyte sequence
```



# 原因

Pythonのopen関数はテキストファイル読み込み処理時にエンコーディングの指定が無い場合、プラットフォーム依存のエンコーディングでデコードしようとし、WindowsではテキストファイルのデフォルトエンコーディングがShift_JIS（≒cp932）であるため、ascii範囲外のバイトコードが入ったutf-8で書かれたkvファイルはデコードエラー＝読み込み失敗となります。

```2. 組み込み関数 — Python 3.6.5 ドキュメント
概要 で触れられているように、Python はバイナリとテキストの I/O を区別します。(mode 引数に 'b' を含めて) バイナリモードで開かれたファイルは、内容をいかなるデコーディングもせずに bytes オブジェクトとして返します。(デフォルトや、 mode 引数に 't' が含まれたときの) テキストモードでは、ファイルの内容は str として返され、バイト列はまず、プラットフォーム依存のエンコーディングか、encoding が指定された場合は指定されたエンコーディングを使ってデコードされます。
```

[2. 組み込み関数 — Python 3.6.5 ドキュメント](https://docs.python.jp/3/library/functions.html#open)



# 対策

あまりやりたくなかった方法なのですが、どうやらこれしか手がなさそうなので仕方がありません。

kivyが正常に動作するなら、Pythonをインストールしたディレクトリの、`Lib/site-packages/kivy` にkivyのコードが一式揃っています。
この中から `Lib/site-packages/kivy/lang/builder.py` を変更します。

GitHubにIssuesとPull Requestが出ており、

[Loading UTF8 KV file with Japanese (multi-byte?) text crashes Kivy under Windows · Issue #5154 · kivy/kivy](https://github.com/kivy/kivy/issues/5154)

[Try first utf-8 encoding by default on reading .kv files. Fixes #5154 · jsbueno/kivy@ea255da](https://github.com/jsbueno/kivy/commit/ea255dac3f0848a1e3c2101d45cf7f770a4b8e25)

まだ採用されていないのでこれを取り込みます。

変更後ファイルはこちら

[kivy/builder.py at ea255dac3f0848a1e3c2101d45cf7f770a4b8e25 · jsbueno/kivy](https://github.com/jsbueno/kivy/blob/ea255dac3f0848a1e3c2101d45cf7f770a4b8e25/kivy/lang/builder.py)

```python:builder.py変更前
    # 前略 kviy1.10.0では276行目あたり
    def load_file(self, filename, **kwargs):
        '''Insert a file into the language builder and return the root widget
        (if defined) of the kv file.
        :parameters:
            `rulesonly`: bool, defaults to False
                If True, the Builder will raise an exception if you have a root
                widget inside the definition.
        '''
        filename = resource_find(filename) or filename
        if __debug__:
            trace('Lang: load file %s' % filename)
        with open(filename, 'r') as fd:
            kwargs['filename'] = filename
            data = fd.read()

            # remove bom ?
            if PY2:
                if data.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
                    raise ValueError('Unsupported UTF16 for kv files.')
                if data.startswith((codecs.BOM_UTF32_LE, codecs.BOM_UTF32_BE)):
                    raise ValueError('Unsupported UTF32 for kv files.')
                if data.startswith(codecs.BOM_UTF8):
                    data = data[len(codecs.BOM_UTF8):]

        return self.load_string(data, **kwargs)
    # 後略
```

ここを

```python:builder.py変更後
    # 前略
    def load_file(self, filename, encoding=None, **kwargs):
        '''Insert a file into the language builder and return the root widget
        (if defined) of the kv file.

        :parameters:
            `rulesonly`: bool, defaults to False
                If True, the Builder will raise an exception if you have a root
                widget inside the definition.

            `encoding`: File charcter encoding. Defaults to utf-8,
                if not given, and utf-8 yields an encoding error, attempts
                loading the file as the native system encoding as
                given by `sys.getdefaultencoding()`
        '''
        filename = resource_find(filename) or filename
        if __debug__:
            trace('Lang: load file %s' % filename)
        if encoding is None:
            encoding_given = False
            encoding = 'utf-8'
        else:
            encoding_given = True

        kwargs['filename'] = filename
        try:
            with open(filename, 'r', encoding=encoding) as fd:
                data = fd.read()
        except UnicodeDecodeError:
            if encoding_given:
                # Don't try to guess encoding if it was passed explicitly
                raise
            Logger.warning(
                'File "{0}" failed to open with  "utf-8" codec. '
                'Trying native system encoding now. Note that '
                'this may cause text differences when the project '
                'is run in other systems'.format(filename)
            )
            with open(filename, 'r') as fd:
                data = fd.read()

            # remove bom ?
            if PY2:
                if data.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
                    raise ValueError('Unsupported UTF16 for kv files.')
                if data.startswith((codecs.BOM_UTF32_LE, codecs.BOM_UTF32_BE)):
                    raise ValueError('Unsupported UTF32 for kv files.')
                if data.startswith(codecs.BOM_UTF8):
                    data = data[len(codecs.BOM_UTF8):]

        return self.load_string(data, **kwargs)    # インデント一つ減らす
    # 後略
```

変更前は `with open(filename, 'r') as fd:` の行にencodingの指定がなく、そのせいで次の行 `data = fd.read()` でデコードエラーが発生して落ちていました。

変更後は `load_file` メソッドで引数 `encoding` を取れるようにし、指定があればその値を、指定がなければ 'utf-8' を指定して `with open(filename, 'r', encoding=encoding) as fd:` するようになっています。

また、Pull Requestに含まれていないのですが、最後の行の `return self.load_string(data, **kwargs)` の部分で、この行のインデントを一つ減らさないと正常に動作しませんのでご注意ください。

*（2018/09/15追記：最後の行のインデント忘れて動かないやんやらかしたので追記）*

### ダメだった対策 1

個別のファイル読み込みで文字コード指定が出来ないなら、全体の文字コード指定を書き換えたらいけるんちゃうのと思って試してみました。

```python
import sys, importlib
importlib.reload(sys)
sys.setdefaultencoding('utf8')
```

Python3ではこう書けばいいみたいな記事があったのですが、そもそもPython3では内部的にだいたい全部Unicodeで処理するようになっていて `sys.setdefaultencoding` メソッドが無くなっており、文法エラーになります。



### ダメだった対策 2

```python
import locale
locale.setlocale(locale.LC_ALL, 'ja_JP.utf-8')
```

ロケールの設定なので文字コードの影響範囲もロケールの表示だけ。



# 感想

春前くらいにMicrosoftがWindowsのデフォルトエンコーディングをUTF-8にするとか何とかという噂を聞いた（正しくはTwitterで見かけた）ような気がしていたのでggってみたのですが、どうやら眉唾だったようです。
そうなればPythonもkivyも変更せずとも解消され、Python勢kivy勢だけでなく全方位の全プログラマが喜ぶところだと思うのですが、後方互換性を大事にするMicrosoftのことなので、まだ当分は変更しなさそうなのかなぁと思いました。

kivyに前述のプルリクエストが取り込まれるまでは、自前でkivyのコードを書き換えていくしかなさそうです。



# 参考資料

[Python 3の各種エンコーディングについて - Qiita](https://qiita.com/methane/items/6e294ef5a1fad4afa843)

[テキストファイルのエンコーディングを自動判定して処理する - Qiita](https://qiita.com/zarchis/items/3258562ebc9570fa05a3)

[Pythonにおける日本語のエンコーディングの検出について - 試験運用中なLinux備忘録](http://d.hatena.ne.jp/kakurasan/20100330/p1)