CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: オブジェクトを漬物 (Pickle) にする

Python の標準ライブラリにある pickle モジュールは Python のオブジェクトを直列化・非直列化するための機能を提供している。 直列化 (Serialize) というのはプログラミング言語においてオブジェクトをバイト列などの表現に変換することを指す。 非直列化 (Deserialize) はその逆で、バイト列を元にオブジェクトを復元することだ。 バイト列などに変換されたデータはファイルなどの形で永続化できる。 最近の典型的な用途でいえば、機械学習で学習済みのモデルを保存して取り回すためなんかに使われる。

ところで、モジュール名でもある pickle という単語は、複数形にすると pickles つまりハンバーガーなどにはさまっているピクルスになる。 きっとメモリ上にある揮発性で賞味期限の短いオブジェクトを、バイト列などの形でファイルなどに長期保存できる状態にすることをピクルス、つまり漬物に見立てているのだろう。 お肉を保存食のベーコンに仕上げるときに漬けこむ液体もピックル液といったりするね。

もくじ

使ってみよう

まず、使用する Python のバージョンは次の通り。 なぜここでバージョンを示したのか不思議に思うかもしれないけど、これは後で意味を持ってくる。

$ python --version
Python 3.5.0

動作確認は Python の REPL を使って行う。 まずは pickle モジュールをインポートしよう。 標準ライブラリなので新たに何か入れるといったことは必要はない。

$ python
>>> import pickle

まずは Python のオブジェクトをバイト列に直列化して、それをファイルに書き込んでみる。

最初に 'sample.pickle' というファイル名を開いて、ファイルオブジェクトをバイナリの書き込みモード (wb) で取得する。 そして、そのファイルオブジェクトに 'Hello, World!' という文字列を pickle.dump() 関数を使って直列化した上で書き込む。

>>> with open('sample.pickle', mode='wb') as f:
...     pickle.dump('Hello, World!', f)
...

これでファイルに Python の文字列オブジェクト (のバイト列表現) が書き込まれた。

では、同じ REPL 上で改めて先ほど保存したファイルを今度はバイナリの読み込みモード (rb) で開き直そう。 取得したファイルオブジェクトを pickle.load() 関数に渡せば、自動的にオブジェクトが復元される。

>>> with open('sample.pickle', mode='rb') as f:
...     pickle.load(f)
...
'Hello, World!'

ファイルに書き込まれていたバイト列からオブジェクトが復元されたことがわかる。

ちなみに Python ではこのように pickle モジュールを使って Python オブジェクトを直列化・非直列化することを Pickle 化 (Pickling)・非 Pickle 化 (Unpickling) と呼ぶ。

ファイルを介すのが面倒くさい

先ほどの例では本物のファイルを介して動作を確認した。 しかし、使うたびに毎回ファイルを介さないといけないのは面倒に感じるはず。 そこで、ここではファイルオブジェクトを使用しない例をふたつ紹介する。

まずひとつ目はインメモリのファイルライクオブジェクトである io.BytesIO クラスを使うやり方。 これはファイルライクオブジェクトといってファイルオブジェクトと同じインターフェースをもったオブジェクトになっている。 ただし、読み書きされた内容はメモリ上に存在するため実際のファイルができたり OS のファイル記述子を消費したりといったことがない。

>>> import io
>>> file = io.BytesIO()
>>> pickle.dump('Hello, World!', file)
>>> file.seek(0)
0
>>> pickle.load(file)
'Hello, World!'

ふたつ目はバイト列を直接扱うやり方。 取り出したバイト列は、もちろんファイルに書き込むことやネットワークで送ることもできるので汎用性の高い方法といえる。

>>> binary = pickle.dumps('Hello, World!')
>>> binary
b'\x80\x03X\r\x00\x00\x00Hello, World!q\x00.'
>>> pickle.loads(binary)
'Hello, World!'

プロトコルバージョン

最初のほうで Python のバージョンを示していた。 先ほどの例では 3.5 を使っていたところを、次は 2.7 を使ってみよう。

$ python --version
Python 2.7.10

この環境で先ほど保存したファイルを読み込むとどうなるだろうか。 なんと同じ手順で読み込んだのに、今度は例外になってしまった。 エラーメッセージでは、どうやら pickle がサポートしていないプロトコルだといっている。

>>> import pickle
>>> with open('sample.pickle', mode='rb') as f:
...     pickle.load(f)
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/pickle.py", line 1378, in load
    return Unpickler(file).load()
  File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/pickle.py", line 858, in load
    dispatch[key](self)
  File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/pickle.py", line 886, in load_proto
    raise ValueError, "unsupported pickle protocol: %d" % proto
ValueError: unsupported pickle protocol: 3

そう、実は pickle がオブジェクトを Pickle 化・非 Pickle 化する際のやり方 (プロトコル) には複数のバージョンが存在している。 プロトコルのバージョンは 0 から始まって、より新しいものほど大きな数字が割り振られている。 新しい (数字が大きい) ものほど効率が良いなど優れたものになっているが、反面それは古いバージョンの Python で使うことができない。 例えば Python 2.7 で使用できるプロトコルのバージョンは最大で 2 までとなっている。 つまり、Python 3.5 と 2.7 の両方で扱うことのできるバイト列を pickle モジュールで作るにはプロトコルのバージョンを 2 以下に指定しなければいけないということになる。

改めて Python 3.5 でプロトコルのバージョンを 2 に指定してファイルに書き込んでみよう。

$ python --version
Python 3.5.0
$ python
>>> import pickle
>>> with open('sample.pickle', mode='wb') as f:
...     pickle.dump('Hello, World!', f, protocol=2)
...

それを Python 2.7 で読み込む。 今度はうまくいった。

$ python --version
Python 2.7.10
$ python
>>> import pickle
>>> with open('sample.pickle', mode='rb') as f:
...     pickle.load(f)
...
u'Hello, World!'

保存されるもの・されないもの

また、残念ながら pickle はオブジェクトであればすべての情報をバイト列にしてくれるわけではない。 例えば、関数やクラスを Pickle 化しようとしても、それにはモジュールとオブジェクトの名前しか記録されない。 つまり、コードはバイト列の中に含まれていないということ。 しかるに非 Pickle 化する環境では Pickle 化したときと同じ名前で同じモジュールとオブジェクトがインポートできる状態になければいけない。

例えば、同じ REPL の中であれば定義した関数は同じものなので問題なく Pickle 化・非 Pickle 化できる。

>>> def greet():
...     print('Hello, World!')
...
>>> binary = pickle.dumps(greet)
>>> restored_obj = pickle.loads(binary)
>>> type(restored_obj)
<class 'function'>
>>> restored_obj()
Hello, World!

では、この関数オブジェクトを 'function.pickle' という名前でファイルに保存しておこう。 そして、一旦その REPL は終了する。

>>> with open('function.pickle', mode='wb') as f:
...     pickle.dump(greet, f)
...
>>> exit()

再度 REPL を起動したら、保存したファイルを読み込んで非 Pickle 化してみよう。 この場合、開き直した REPL には関数 greet() の定義がないことから例外になる。 ファイルに保存されたバイト列の中には名前の情報しかなくコードの情報がないので復元できないということだ。

$ python
>>> import pickle
>>> with open('function.pickle', mode='rb') as f:
...     pickle.load(f)
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
AttributeError: Can't get attribute 'greet' on <module '__main__' (built-in)>

では、同じ名前を持った異なるコードだとどうなるだろうか? 今度は同じ名前をもった greet() 関数を定義しているが標準出力に書き出している内容が異なる。

>>> def greet():
...     print('Hello, Pickle!')
...
>>> with open('function.pickle', mode='rb') as f:
...     function = pickle.load(f)
...
>>> function()
Hello, Pickle!

実行結果をみると、pickle モジュールは名前は同じだが内容は異なる関数を素直に読み込んで実行してしまった。

つまり、pickle モジュールは読み込む内容を信用しているということだ。 システムに危険を及ぼすような内容がそれに含まれていたとしても pickle モジュールはそれを防いでくれない。 したがって、脆弱性になるので信用できない内容は非 Pickle 化してはいけない。

クラスインスタンス

次はクラスインスタンスを Pickle 化してみよう。 インスタンスのもっている属性の情報はちゃんとバイト列の中に含まれるので安心してほしい。 (ただし、もちろんクラス自体のもつコード、例えばメソッドがどういった処理をもっているかについてはバイト列に含まれない)

まずはユーザ定義クラス MyClass を定義する。 このクラスは属性としてメンバ変数 msg とメソッド greet() をもっている。 メソッド greet() ではメンバ変数 msg を表示する。 ユーザ定義クラス MyClass をインスタンス化したら、それを Pickle 化してファイルに保存しよう。 終わったら一旦 REPL を終了させておく。

>>> class MyClass(object):
...     def __init__(self, msg):
...         self.msg = msg
...     def greet(self):
...         print(self.msg)
...
>>> obj = MyClass('Hello, World!')
>>> with open('class.pickle', mode='wb') as f:
...     pickle.dump(obj, f)
...
>>> exit()

もう一度 REPL を立ち上げてファイルからクラスを非 Pickle 化する。 先ほど関数を例にして確認した通りだけど、クラスについてもコードはバイト列の中に保存されない。 つまり、同じ名前のモジュールとオブジェクトがなければ非 Pickle 化しようとしても例外になってしまう。

$ python
>>> import pickle
>>> with open('class.pickle', mode='rb') as f:
...     pickle.load(f)
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
AttributeError: Can't get attribute 'MyClass' on <module '__main__' (built-in)>

そのため再度ユーザ定義クラス MyClass を用意する必要がある。 今回は REPL を使ったのでこんな面倒なことになっているけど、通常であれば自分で書いた Pickle を扱うモジュールまたはパッケージの中にクラスは含まれているはず。

>>> class MyClass(object):
...     def __init__(self, msg):
...         self.msg = msg
...     def greet(self):
...         print(self.msg)
...

改めてファイルからインスタンスを読み込んでメソッドを実行しよう。 ここで適切にメンバ変数 msg の内容が出力されていることから、その情報が Pickle 化したバイト列の中に含まれていることが確認できた。

>>> with open('class.pickle', mode='rb') as f:
...     obj = pickle.load(f)
...
>>> obj.greet()
Hello, World!

Pickle 化できないもの

しかし、メンバ変数の内容が Pickle 化できるからといって、何も考えずにオブジェクトをそのままバイト列にできるかというとそうではない。 Python には Pickle 化できないオブジェクトが一部にあるからだ。 例えばファイルオブジェクトはその代表といえる。 ファイルオブジェクトを Pickle 化しようとすると、以下のように TypeError 例外になってしまう。

>>> file = open('/dev/null', mode='rb')
>>> pickle.dumps(file)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: cannot serialize '_io.BufferedReader' object

これはつまり、Pickle 化できないオブジェクトがメンバ変数に含まれるインスタンスは、それが丸ごと Pickle 化できないことを意味する。 次のサンプルコードでは、ユーザ定義クラス Sample がメンバ変数に Pickle 化できないファイルオブジェクトをもっている。 そして main() 関数ではそのインスタンスの Pickle 化・非 Pickle 化を試みている。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import pickle


class Sample(object):

    def __init__(self, filename):
        # 文字列は Pickle 化できる
        self.filename = filename

        # ファイルオブジェクトは Pickle 化できない
        self.file = open(filename, mode='rb')


def main():
    obj = Sample('/dev/null')
    binary = pickle.dumps(obj)
    restored_obj = pickle.loads(binary)
    print(restored_obj.filename)
    print(restored_obj.file)


if __name__ == '__main__':
    main()

上記のサンプルコードを実行してみよう。

$ python picklefile.py 
Traceback (most recent call last):
  File "picklefile.py", line 28, in <module>
    main()
  File "picklefile.py", line 21, in main
    binary = pickle.dumps(obj)
TypeError: cannot serialize '_io.BufferedReader' object

やはりインスタンスに Pickle 化できないオブジェクトが含まれるため TypeError 例外になってしまった。

Pickle 化できないオブジェクトを扱う方法

では、Pickle 化できないオブジェクトがメンバ変数に含まれる時点でお手上げかというとそうではない。 Python ではこの問題に対処するための方法が用意されている。 それが特殊メソッド __getstate__() と __setstate__() を使うやり方だ。 このふたつの特殊メソッドはオブジェクトが Pickle 化・非 Pickle 化される際の処理に対応している。 つまり、その挙動をオーバーライドできるということだ。

次のサンプルコードではユーザ定義クラス Sample に __getstate__() と __setstate__() を実装することで Pickle 化できるようにしている。 具体的な処理としては、Pickle 化されるときに呼ばれる __getstate__() で Pickle 化できないオブジェクトを Pickle 化の対象から除外している。 そして、非 Pickle 化されるときに呼ばれる __setstate__() では、ファイル名からファイルオブジェクトを改めて作りなおしている。 尚、非 Pickle 化される際には __init__() メソッドは呼ばれない点に注意が必要となる。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import pickle


class Sample(object):

    def __init__(self, filename):
        """非 Pickle 化されるときは呼ばれない"""

        # 文字列は Pickle 化できる
        self.filename = filename

        # ファイルオブジェクトは Pickle 化できない
        self.file = open(filename, mode='rb')

    def __getstate__(self):
        """Pickle 化されるとき呼ばれる"""

        # オブジェクトの持つ属性をコピーする
        state = self.__dict__.copy()

        # Pickle 化できない属性を除去する
        del state['file']

        # Pickle 化する属性を返す
        return state

    def __setstate__(self, state):
        """非 Pickle 化されるとき呼ばれる"""

        # オブジェクトの持つ属性を復元する
        self.__dict__.update(state)

        # Pickle 化できなかった属性を作りなおす
        self.file = open(self.filename, mode='rb')


def main():
    obj = Sample('/dev/null')
    binary = pickle.dumps(obj)
    restored_obj = pickle.loads(binary)
    print(restored_obj.filename)
    print(restored_obj.file)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python picklefile.py 
/dev/null
<_io.BufferedReader name='/dev/null'>

今度は例外にならず適切にオブジェクトを Pickle 化・非 Pickle 化できた。

まとめ

  • Python のオブジェクトをファイルなどに保存するには pickle モジュールを使う
  • オブジェクトをバイト列などの表現に直列化・非直列化することは Pickle 化・非 Pickle 化という
  • 脆弱性になるので信用できない内容を非 Pickle 化してはいけない
  • Pickle 化・非 Pickle 化する際のプロトコルにはバージョンがあって適切な番号を選ぶ必要がある
  • ソースコードの情報は Pickle 化されない
  • オブジェクトの中には Pickle 化できないものがある
  • Pickle 化できないオブジェクトを含むインスタンスを扱うには特殊メソッド __getstate__() と __setstate__() を使う

いじょう。

Python: ファイルオブジェクトのクローズについて

Python でファイルシステム上にあるファイルを扱うには open() 関数などを使ってファイルオブジェクトを取得する。 しかし、使い終わったファイルオブジェクトはちゃんと close() メソッドを呼んで後片付けをしなければいけない。 そして、同時に開くファイルオブジェクトの数は少なくしなければいけない。 今回は一体なぜそんなことをしなければいけないのか、どうすればいいのかについて書いてみる。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.11.1
BuildVersion:   15B42
$ python --version
Python 3.5.0

たくさん開いてみよう

クローズしなければいけない理由は、多くのファイルを開いたときにわかる。 Python の REPL を使って、そこらへんにあるファイルをひたすら開いてみよう。 ここでは '/dev/null' デバイスを使うことにする。 その際、取得したファイルオブジェクトはリストに格納することで GC (ガーベジコレクション) の対象にならないようにしよう。

やってみると OSError 例外になることがわかる。

$ python
>>> files = []
>>> while True:
...     file = open('/dev/null')
...     files.append(file)
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
OSError: [Errno 24] Too many open files: '/dev/null'

この状態になると、もうそれ以上ファイルを開くことはできない。

>>> open('/dev/null')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
OSError: [Errno 24] Too many open files: '/dev/null'

しかし、既存のファイルオブジェクトのひとつをクローズすると、新たにひとつだけファイルを開けるようになる。 つまり、同時に開けるファイルオブジェクトの数には上限があるということがわかった。

>>> files[0].close()
>>> open('/dev/null')
<_io.TextIOWrapper name='/dev/null' mode='r' encoding='UTF-8'>

開くことができたファイルオブジェクトの数をみると、何やら中途半端な数になっていることがわかる。 もっとも近い 2 の倍数でいうと 256 に 3 足りない。

>>> len(files)
253

この 3 足りない理由は明確で、標準入力、標準出力、標準エラー出力という 3 つのファイルオブジェクトをデフォルトで開いているから。

>>> import sys
>>> sys.stdin
<_io.TextIOWrapper name='<stdin>' mode='r' encoding='UTF-8'>
>>> sys.stdout
<_io.TextIOWrapper name='<stdout>' mode='w' encoding='UTF-8'>
>>> sys.stderr
<_io.TextIOWrapper name='<stderr>' mode='w' encoding='UTF-8'>

数字の正体

では、一体この 256 という数字はどこからきているのか。 Python がファイルオブジェクトを開いている状態で、別のターミナルから lsof コマンドを実行してみよう。 このコマンドは各プロセスが開いているファイルの情報を見ることができる。

$ lsof -c python
COMMAND     PID    USER   FD     TYPE             DEVICE  SIZE/OFF    NODE NAME
...(省略)...
python3.5 81267 amedama  246r     CHR                3,2       0t0     304 /dev/null
python3.5 81267 amedama  247r     CHR                3,2       0t0     304 /dev/null
python3.5 81267 amedama  248r     CHR                3,2       0t0     304 /dev/null
python3.5 81267 amedama  249r     CHR                3,2       0t0     304 /dev/null
python3.5 81267 amedama  250r     CHR                3,2       0t0     304 /dev/null
python3.5 81267 amedama  251r     CHR                3,2       0t0     304 /dev/null
python3.5 81267 amedama  252r     CHR                3,2       0t0     304 /dev/null
python3.5 81267 amedama  253r     CHR                3,2       0t0     304 /dev/null
python3.5 81267 amedama  254r     CHR                3,2       0t0     304 /dev/null
python3.5 81267 amedama  255r     CHR                3,2       0t0     304 /dev/null

実行結果の中で FD という列の数字が 255 まで振られていることに気がつく。 この FD というのは File Descriptor (ファイル記述子) の略になっている。 つまり、ファイルオブジェクトひとつに対してこのファイル記述子というものがひとつ割り当てられている。

なぜ上限があるのかという疑問については、このファイル記述子というのものが OS の管理している有限なリソースだからだ。 つまり、各プロセスで好き勝手に使われると困る。

先ほど出てきた 256 という上限の値は Python からだと resource モジュールを使って確認できる。

$ python -c "import resource; print(resource.getrlimit(resource.RLIMIT_NOFILE)[0])"
256

コマンドラインで確認するときは ulimit コマンドを使う。

$ ulimit -n
256

試しに ulimit コマンドで上限を 64 に変更した場合の挙動を確認しておこう。 64 から 3 を引いた値の 61 個のファイルオブジェクトを開いておけば、次に開こうとしたときに例外になるはずだ。

$ ulimit -n 64
$ python
>>> files = [open('/dev/null') for _ in range(61)]
>>> len(files)
61

この試みは見事に上手くいく。

>>> open('/dev/null')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
OSError: [Errno 24] Too many open files: '/dev/null'

ちなみに ulimit で設定される上限はソフトリミットと呼ばれるもので、この他にカーネルパラメータで設定するハードリミットと呼ばれるものもある。 Mac OS X であれば kern.maxfiles が OS 全体のファイル記述子の上限で、kern.maxfilesperproc がプロセスあたりの上限 (ハードリミット) に対応しているようだ。 つまり、ひとつのプロセスが使えるファイル記述子の数の上限はソフトリミットとハードリミットという二段構えになっている。

$ sysctl -a | grep maxfile
kern.maxfiles: 12288
kern.maxfilesperproc: 10240

上限はプロセス単位でかかるので、別のターミナル (つまり別のプロセス) からであれば新たにファイルを開くこともできる。 もちろん、これは OS 全体のファイル記述子の上限に達していない場合に限る。

$ python
>>> open('/dev/null')
<_io.TextIOWrapper name='/dev/null' mode='r' encoding='UTF-8'>

で、結局どうすればいいのか

守らなければいけないことはふたつだけ。

  • ファイルオブジェクトを使い終わったら確実にクローズする
  • 一度にたくさんのファイルオブジェクトを開かない

ひとつ目は、失敗すると開いたままのファイル記述子がずっと放置されることになって、そのうち上限に達したりする。 これを FD リークと呼ぶ。 閉じ忘れを防ぐにはファイルオブジェクトを with 文と共に使おう。

>>> with open('/dev/null', mode='rb') as f:
...     f.read(1)
...
b''

ふたつ目はマルチスレッドで処理をしているときなどに、一度に開くファイルオブジェクトの数を常に意識しておかないと瞬間的に上限に達してしまう可能性がある。

GC に回収されたファイルオブジェクトはどうなる?

ここまで見てくると、クローズされていない状態で GC に回収されたファイルオブジェクトによって FD リークが起きないのか、という点が気になる。

まずは挙動から確認しておこう。 先ほどと同様に、まずは GC に回収されないようにリストに格納する形でファイルオブジェクトを上限まで開いておく。

>>> files = []
>>> while True:
...     file = open('/dev/null')
...     files.append(file)
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
OSError: [Errno 24] Too many open files: '/dev/null'

そして、ファイルオブジェクトの入ったリストの参照を開放する。 CPython であれば参照カウント方式にもとづいた GC を使っているので、この瞬間に回収される (はず) 。

>>> files = None

PyPy を使っている場合には、異なる方式の GC なので参照がなくなったからといってその瞬間に回収されることはない。 なので gc モジュールを使って明示的に実行するといいんじゃないだろうか。 ただし、仕様上は以下を実行しても回収される保証はない。

>>> import gc; gc.collect()  # PyPy を使っている場合
0

この状態で新たにファイルオブジェクトを開くと、ちゃんとファイルオブジェクトを取得できる。 つまり、どうやら GC に回収されたファイルオブジェクトは自動的にクローズされるようだ。

>>> open('/dev/null')
<_io.TextIOWrapper name='/dev/null' mode='r' encoding='UTF-8'>

この挙動が仕様にもとづいたものなのかを軽く調べてみたところ、次の公式ドキュメントの記述が見つかった。

11.6. tempfile — Generate temporary files and directories — Python 3.5.2 documentation

(including an implicit close when the object is garbage collected)

ということで GC に回収されたファイルオブジェクトが自動的にクローズされるのは仕様のようだ。

Python: コマンドラインパーサの Click で独自の型を使う

これは Python Advent Calendar 2015 の 2 日目の記事です。

サードパーティ製のコマンドラインパーサ Click がとても便利で標準ライブラリの argparse を使っている場合じゃない件については以前このブログで書きました。 blog.amedama.jp

今回は、その記事では紹介できなかった、独自の型を定義して Click で使う方法について書いてみます。

インストール

まずは何はともあれ Click をインストールしておきましょう。

$ pip install Click
Collecting Click
  Downloading click-6.2-py2.py3-none-any.whl (70kB)
    100% |████████████████████████████████| 73kB 133kB/s 
Installing collected packages: Click
Successfully installed Click-6.2

Click の型って?

ここで Click の型と呼んでいるのは click.option() や click.argument() で指定できる引数 type に指定できる値のことです。 例えば click.option() の引数 type に組み込み型の int を指定した場合のサンプルコードを以下に示します。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import click


@click.command()
@click.option('--integer', '-I', type=int)
def main(integer):
    click.echo(integer)
    click.echo(type(integer))


if __name__ == '__main__':
    main()

上記のオプション --integer/-I には int として解釈できる値が渡されると、それが int 型にキャストされた状態で引数 integer にインジェクトされます。 そして、入力された内容が int として解釈できない場合にはコマンドの実行がエラーとなります。

$ python typeint.py --integer 100
100
<type 'int'>
$ python typeint.py --integer foo
Usage: typeint.py [OPTIONS]

Error: Invalid value for "--integer" / "-I": foo is not a valid integer

独自の型を追加する

先ほど例では int があらかじめ Click で使えるようになっていましたが、これと同じ考え方で独自の型が使えると便利そうです。 今回は試しに IPv4/IPv6 アドレスを受け取ることができる独自の型を定義してみます。

Python 3.3+ であれば Python に IP アドレスを扱うための標準ライブラリ ipaddress が用意されているのでこれを使うことにします。 もし Python 2.x を使う場合には ipaddress のバックポートである py2-ipaddress があるので、別途インストールしてください。

$ pip install py2-ipaddress  # Python 2.x の場合

早速サンプルコードです。 独自の型を作るには click.ParamType を継承したクラスを定義して convert() メソッドをオーバーライドします。 引数の value がユーザの入力した値に対応するので、これを元に IP アドレスのオブジェクトに変換してメソッドの返り値にします。 もし、変換できない (適切な IP アドレスではない) 値が入力された場合には click.ParamType#fail() メソッドでそれを通知します。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import ipaddress

import click


class IPAddressType(click.ParamType):
    name = 'ipaddress'

    def convert(self, value, param, ctx):
        try:
            return ipaddress.ip_address(value)
        except ValueError:
            message = '{value} is not a valid ip address'.format(value=value)
            self.fail(message, param, ctx)


IP_ADDRESS = IPAddressType()


@click.command()
@click.option('--ip-address', '-I', type=IP_ADDRESS)
def main(ip_address):
    click.echo(ip_address)
    click.echo(type(ip_address))


if __name__ == '__main__':
    main()

それでは、上記を実行してみましょう。

$ python typeip.py --ip-address 192.168.2.1
192.168.2.1
<class 'ipaddress.IPv4Address'>
$ python typeip.py --ip-address 2001:db8::1
2001:db8::1
<class 'ipaddress.IPv6Address'>

IPv4/IPv6 アドレスとして解釈できる値が入力されると、それが適切なオブジェクトとなって引数にインジェクトされています。

もちろん、IP アドレスとして解釈できない値が入力されるとエラーになります。

$ python typeip.py --ip-address 123.456.789.123
Usage: typeip.py [OPTIONS]

Error: Invalid value for "--ip-address" / "-I": 123.456.789.123 is not a valid ip address

ばっちりですね。

Python: pip と Wheel キャッシュについて

これは Python Advent Calendar 2015 の 1 日目の記事です。

Wheel というのは、最近になって導入された Python の新しいパッケージング規格です。 今回はその Wheel を pip が積極的にキャッシュすることで気づいた点について書いてみます。

今回使用した環境は次の通りです。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.11.1
BuildVersion:   15B42
$ pip list | egrep (pip|setuptools|wheel)
pip (7.1.2)
setuptools (18.5)
wheel (0.26.0)

気づけば Wheel を使っていた

Python を使っている方であれば、パッケージマネージャの pip は使ったことがあるとおもいます。 その pip ですが、実は最近のバージョンでは知らず知らずのうちに裏側で Wheel を使うような仕組みになっていることをご存知でしょうか。 実は pip を使って PyPI のパッケージをインストールしようとしたときには、必ず Wheel を使ってインストールするようになっています。

PyPI に Wheel があるとき

PyPI にはたくさんのパッケージが登録されていますが、その中には Wheel が用意されているものといないものがあります。 これはパッケージを作って登録したひとがソースコード配布物とは別に Wheel パッケージもアップロードするか否かによって変わります。 まずは Wheel が用意されている場合ですが、これはその Wheel がインストールにそのまま使われます。

試しに PyPI に Wheel が用意されているパッケージの代表として requests をインストールしてみましょう。

$ pip install requests
Collecting requests
  Downloading requests-2.8.1-py2.py3-none-any.whl (497kB)
    100% |████████████████████████████████| 499kB 545kB/s 
Installing collected packages: requests
Successfully installed requests-2.8.1
$ pip list | grep requests
requests (2.8.1)

ログから Wheel (*.whl) がダウンロードされてインストールに使われたことがわかります。

PyPI に Wheel がないとき

PyPI に Wheel がなくてソースコード配布物しか用意されていない場合はどうでしょうか。 実は、その場合にも内部的には Wheel が使われるようになっています。

これがどういうことなのか確認するため、Wheel が用意されていないパッケージの代表として lxml をインストールしてみましょう。 この作業には lxml に含まれる C 拡張モジュールのコンパイルが必要なため時間がかかります。 time コマンドを使ってインストールに要した時間も記録しておきましょう。

$ time pip install lxml
Collecting lxml
  Downloading lxml-3.4.4.tar.gz (3.5MB)
    100% |████████████████████████████████| 3.5MB 138kB/s 
Building wheels for collected packages: lxml
  Running setup.py bdist_wheel for lxml
  Stored in directory: /Users/amedama/Library/Caches/pip/wheels/62/29/26/11383932dbed36e9fe68552fc755a96cfc6fa5833d948620da
Successfully built lxml
Installing collected packages: lxml
Successfully installed lxml-3.4.4
pip install lxml  46.51s user 1.84s system 94% cpu 51.298 total

インストールには 46 秒かかりました。

ダウンロードされたのはソースコード配布物 (*.tar.gz) ですが、"Building wheels for collected packages: lxml" というログからもわかる通り、それを Wheel にビルドした上でインストールしていることがわかります。 そして、"Stored in directory: " となっているところは今回の本題となる Wheel のキャッシュです。

つまり、最近の pip は PyPI に Wheel が用意されていない場合、ソースコード配布物を Wheel にビルドした上でインストールし、更にそれをキャッシュするようになっているということです。

Wheel の何がうれしいのか?

Wheel の利点は色々ありますが、一番分かりやすいのは C 拡張モジュールがコンパイル済みの状態で同梱されているところだとおもいます。

試しに、先ほどインストールした lxml を一旦アンインストールした上で、もう一度インストールしてみましょう。 今度もインストールの際には time コマンドを使って要した時間を記録します。

$ pip uninstall -y lxml
Uninstalling lxml-3.4.4:
  Successfully uninstalled lxml-3.4.4
$ time pip install lxml
Collecting lxml
Installing collected packages: lxml
Successfully installed lxml-3.4.4
pip install lxml  0.71s user 0.11s system 98% cpu 0.825 total

なんと今度は 1 秒かからずにインストールが終わっています。 これはキャッシュされた Wheel を使ってインストールが行われたため、パッケージのダウンロードと C 拡張モジュールをコンパイルする時間がいらなくなったためです。

Wheel キャッシュにひそむ罠

このようにインストールにかかる時間の短縮に絶大な効果を発揮する Wheel のキャッシュですが、その反面思わぬ事態を招くこともあることに最近気付きました。

試しに pudb というパッケージをインストールしてみましょう。 これは Python 向けのグラフィカルなデバッガです。 このパッケージには、まだ PyPI に Wheel が用意されていないためソースコード配布物を手元でビルドしてそれをローカルにキャッシュすることになります。

$ pip install pudb==2015.4.1
Collecting pudb
  Downloading pudb-2015.4.1.tar.gz (49kB)
    100% |████████████████████████████████| 53kB 2.3MB/s 
Collecting urwid>=1.1.1 (from pudb)
  Downloading urwid-1.3.1.tar.gz (588kB)
    100% |████████████████████████████████| 589kB 635kB/s 
Collecting pygments>=1.0 (from pudb)
  Downloading Pygments-2.0.2-py3-none-any.whl (672kB)
    100% |████████████████████████████████| 675kB 666kB/s 
Building wheels for collected packages: pudb, urwid
  Running setup.py bdist_wheel for pudb
  Stored in directory: /Users/amedama/Library/Caches/pip/wheels/f5/42/17/0cb57e3de0702f797b1f19a1dcbe84b04d2b9ca3e750d75176
  Running setup.py bdist_wheel for urwid
  Stored in directory: /Users/amedama/Library/Caches/pip/wheels/20/ed/aa/f28c05890e0dd72e7023a9fb3e5966a0a9d7a1e9682a29a94f
Successfully built pudb urwid
Installing collected packages: urwid, pygments, pudb
Successfully installed pudb-2015.4.1 pygments-2.0.2 urwid-1.3.1

pudb のインストールが終わると pudb3 というコマンドが使えるようになります。

$ pudb3
Usage: pudb3 [options] SCRIPT-TO-RUN [SCRIPT-ARGUMENTS]

Options:
  -h, --help          show this help message and exit
  -s, --steal-output  
  --pre-run=COMMAND   Run command before each program run

この末尾についている 3 という数字は pudb 自体のバージョンではなく、インストール先の Python バージョンによって付与されるようになっています。

github.com

つまり、使っているのが 3.x 系であれば末尾に '3' が付与されて、2.x 系であればつかないという寸法です。 今回は使用したバージョンが 3.4 だったのでつきました。

$ python --version
Python 3.4.3

それでは、今度は Python 2.7 の仮想環境を作りなおした上で、改めて pudb をインストールしてみましょう。 これには前述した通り、先ほどビルドした Wheel のキャッシュが使われます。 なんだか嫌な予感がしてきたでしょうか? 尚、仮想環境の作成手順については省略しています。

$ python --version
Python 2.7.10
$ pip install pudb==2015.4.1
Collecting pudb
Collecting urwid>=1.1.1 (from pudb)
  Using cached urwid-1.3.1.tar.gz
Collecting pygments>=1.0 (from pudb)
  Downloading Pygments-2.0.2-py2-none-any.whl (672kB)
    100% |████████████████████████████████| 675kB 530kB/s 
Building wheels for collected packages: urwid
  Running setup.py bdist_wheel for urwid
  Stored in directory: /Users/amedama/Library/Caches/pip/wheels/20/ed/aa/f28c05890e0dd72e7023a9fb3e5966a0a9d7a1e9682a29a94f
Successfully built urwid
Installing collected packages: urwid, pygments, pudb
Successfully installed pudb-2015.4.1 pygments-2.0.2 urwid-1.3.1

インストール先の Python のバージョンが 2.7 なのだから、コマンド名に 3 はつかないはずです。 しかし、そうはなりません。 なぜか 3 が末尾に付与されたコマンドがインストールされています。

$ pudb
zsh: command not found: pudb
$ pudb3
Usage: pudb3 [options] SCRIPT-TO-RUN [SCRIPT-ARGUMENTS]

Options:
  -h, --help          show this help message and exit
  -s, --steal-output  
  --pre-run=COMMAND   Run command before each program run

一体、何が起こったのか?

ひと言にいえば、ビルドした環境の Python バージョンによってコマンド名が pudb3 に固定された状態でその Wheel がキャッシュされてしまったせいです。 誤解を避けるためにあらかじめ断っておきますが、これは Wheel というシステムの問題ではなく設定の不備によって引き起こされています。 適切に Wheel の設定が行われている限りこの問題は起きません。

それでは、詳しく見ていきましょう。 Wheel では、パッケージの作り方によっては Python のバージョン 2.x と 3.x に共通で使用できる単一の Wheel ファイル (*.whl) を作ることもできます。 これは、setup.cfg の wheel セクションに universal フラグを 1 にすることでそれを示すことになっています。 そして先ほどインストールした pudb (バージョン 2015.4.1) についてもそのようになっています。

github.com

しかし、前述した通り pudb はインストール先の Python のバージョン毎にコマンド名を変更するようになっています。 そして、コマンド名は Wheel ファイルをビルドするタイミングで決まってしまいます。 つまり、厳密にいえばひとつの Wheel ファイルをふたつのメジャーバージョンで使い回すことはできないわけです。

試しに手元で pudb のソースコードをチェックアウトして手動で Wheel ファイルをビルドしてみましょう。 できあがった Wheel のファイル名には py2.py3 というバージョン番号が入っている通り、どちらのバージョンでも使えることを示しています。

$ git clone https://github.com/inducer/pudb.git
$ cd pudb
$ git checkout -b v2015.4.1 v2015.4.1
$ python setup.py bdist_wheel
$ ls dist
pudb-2015.4.1-py2.py3-none-any.whl

しかし、実際には universal = 0 とすることで、Python 2.x と 3.x で別々に Wheel ファイルをビルドする必要があったわけです。

$ sed -i -e "s:^\(universal = \)1$:\10:" setup.cfg
$ cat setup.cfg
[flake8]
ignore = E126,E127,E128,E123,E226,E241,E242,E265,W503,E402
max-line-length=85
[wheel]
universal = 0
$ python setup.py bdist_wheel
$ ls dist | grep py2-
pudb-2015.4.1-py2-none-any.whl

設定に不備があるパッケージを扱うには?

ここまで見てきたように、設定の不備があるパッケージはたまにあるようです。 pip が積極的に Wheel ファイルをキャッシュするようになったのは最近のことなので、まだまだ問題に気づかれていないのだとおもいます。

では、設定に不備があるパッケージをそれでもインストールしなければならないときにはどうすればいいのでしょうか。 実は pip には --no-cache-dir というオプションがあります。 これを使えば Wheel ファイルをキャッシュせずにインストールできます。

$ pip install --no-cache-dir pudb

既にキャッシュされてしまった Wheel を削除したいときには、以下のコマンドで Wheel がキャッシュされている場所を調べましょう。

$ find $(python -c "from pip.locations import USER_CACHE_DIR; print(USER_CACHE_DIR)") -name "*.whl"
/Users/amedama/Library/Caches/pip/wheels/20/ed/aa/f28c05890e0dd72e7023a9fb3e5966a0a9d7a1e9682a29a94f/urwid-1.3.1-cp27-none-macosx_10_11_x86_64.whl
/Users/amedama/Library/Caches/pip/wheels/20/ed/aa/f28c05890e0dd72e7023a9fb3e5966a0a9d7a1e9682a29a94f/urwid-1.3.1-cp34-cp34m-macosx_10_11_x86_64.whl
/Users/amedama/Library/Caches/pip/wheels/62/29/26/11383932dbed36e9fe68552fc755a96cfc6fa5833d948620da/lxml-3.4.4-cp34-cp34m-macosx_10_11_x86_64.whl
/Users/amedama/Library/Caches/pip/wheels/f5/42/17/0cb57e3de0702f797b1f19a1dcbe84b04d2b9ca3e750d75176/pudb-2015.4.1-py2.py3-none-any.whl

まとめ

今回は、最近の pip が新しいパッケージング規格である Wheel をこっそりと裏側で使っていることを示しました。 その上で、Wheel をビルドする際の設定に不備があると、まずい状態の Wheel ファイルがキャッシュされてしまい、ハマる原因になることを解説しました。 パッケージを作る際には、setup.cfg の中にある wheel セクションの universal フラグを慎重に設定する必要があります。

補足

今回の件については pudb にプルリクエストを送って既にマージされたため、次のリリースでは直っているはずです。 github.com

おまけ

今回の件で pip のソースコードをちょこっと読みました。

以下は pip がキャッシュを取得するメカニズムです。 PyPI のソースコード配布物を示す URL から生成された Link オブジェクトが、WheelCache#cached_wheel() メソッドを通すとローカルのキャッシュを指し示すものに変換されています。

>>> from pip import wheel
>>> from pip import index
>>> from pip import locations
>>> 
>>> url = 'https://pypi.python.org/packages/source/p/pudb/pudb-2015.4.1.tar.gz#md5=2589255f1885a9eab10f666ca0f6204c'
>>> link = index.Link(url)
>>> cache = wheel.WheelCache(locations.USER_CACHE_DIR, index.FormatControl(set(), set()))
>>> cache.cached_wheel(link, 'pudb')
<Link file:///Users/amedama/Library/Caches/pip/wheels/f5/42/17/0cb57e3de0702f797b1f19a1dcbe84b04d2b9ca3e750d75176/pudb-2015.4.1-py2.py3-none-any.whl>

また、これもソースコードを読んでわかったのですが Wheel キャッシュに使われる長いディレクトリ名は URL を SHA224 でハッシュしたものになっているようです。

>>> url = 'https://pypi.python.org/packages/source/p/pudb/pudb-2015.4.1.tar.gz#md5=2589255f1885a9eab10f666ca0f6204c'
>>> import hashlib
>>> hashlib.sha224(url.encode()).hexdigest()
'f542170cb57e3de0702f797b1f19a1dcbe84b04d2b9ca3e750d75176'

Python: functools.partial() で関数やメソッドを部分適用する

これまで存在すら知らなかったんだけど、標準ライブラリの functools.partial() はなかなか面白く使えそう。 これを使うと関数やメソッドの引数の一部をある値に固定した形で新しい呼び出し可能オブジェクトを作ることができる。

最初の例として functools.partial() の動作確認に使う関数を定義しておこう。 この関数 add() は単にふたつの引数を足し算するもの。

>>> def add(x, y):
...     return x + y
... 

それでは functools.partial() を使って、先ほど定義した add() 関数のひとつ目の引数 x に 1 を部分適用した新しい関数 add_1() を作ってみよう。 ちなみにキーワード引数も使えるので、もし y を部分適用したい場合には functools.partial(add, y=1) になる。

>>> import functools
>>> add_1 = functools.partial(add, 1)

functools.partial() によって作られた新しい関数 add_1() はひとつの引数だけで呼び出すことができる。

>>> add_1(1)
2
>>> add_1(2)
3

とはいえ、上記の例を見ても「使い方は分かったけど使い所はさっぱり分からない」という感じだとおもう。 次はもっと実用的な例を挙げてみよう。

次は itertools.count() を例にしてみよう。 この関数は整数をインクリメントしながら順番に返すイテレータを返す。 イテレータは next() 関数に渡すことで次の要素が得られる。 毎回 next() 関数を使わなきゃいけないのは割りとめんどくさいね。

>>> import itertools
>>> g = itertools.count()
>>> next(g)
0
>>> next(g)
1
>>> next(g)
2

では、functools.partial() を使って next() 関数の引数にイテレータを部分適用してやろう。 これで、引数なしの呼び出しでイテレータの内容を返す呼び出し可能オブジェクトが得られる。

>>> callable = functools.partial(next, g)
>>> callable()
3
>>> callable()
4
>>> callable()
5

このように、引数のいらない呼び出し可能オブジェクトに加工しておくと扱いやすくなる場面も多い。 例えば組み込み関数 iter() にこれを渡した上で、sentinel (番兵) の要素を指定しておけば、その値が得られるまでループするイテレータが得られる。 元々は無限ループするイテレータを、ある特定の値が得られると止まるイテレータにラップできるということ。

>>> ite = iter(callable, 10)
>>> ite
<callable-iterator object at 0x10a4f6a90>
>>> list(ite)
[6, 7, 8, 9]

特に iter() と組み合わせて使うイディオムは強力そうだ。

Python: Janome で手軽に形態素解析する

Janome は Pure Python で実装された日本語の形態素解析ライブラリ。 形態素解析というのは文章から意味をもった最小の構成単位となる形態素を取り出すことをいう。 他の日本語の形態素解析ライブラリは Python から使えてもバインディングが提供されているだけでドキュメントがイマイチだったり、あるいはインストールが大変だったりということも多々あるんだけど Janome はそんなことがない。

インストール

インストールは実に簡単。 pip を使ってさっくりできる。

$ pip install janome

使ってみる

使い方も至ってシンプルで janome.tokenizer.Tokenizer さえ知っていればいい。 Tokenizer#tokenize() メソッドに日本語のユニコード文字列を渡すだけで解析ができる。 解析結果はリストで返ってくる。

>>> from janome.tokenizer import Tokenizer
>>> t = Tokenizer()
>>> tokens = t.tokenize(u'吾輩は猫である')
>>> for token in tokens:
...     print(token)
...
吾輩  名詞,代名詞,一般,*,*,*,吾輩,ワガハイ,ワガハイ
は 助詞,係助詞,*,*,*,*,は,ハ,ワ
猫 名詞,一般,*,*,*,*,猫,ネコ,ネコ
で 助動詞,*,*,*,特殊・ダ,連用形,だ,デ,デ
ある  助動詞,*,*,*,五段・ラ行アル,基本形,ある,アル,アル

リストには janome.tokenizer.Token というクラスのインスタンスが入っている。

>>> tokens
[<janome.tokenizer.Token object at 0x10b307748>, <janome.tokenizer.Token object at 0x10b307780>, <janome.tokenizer.Token object at 0x10b3077b8>, <janome.tokenizer.Token object at 0x10b3077f0>, <janome.tokenizer.Token object at 0x10b307828>]

このオブジェクトからは Token#surface で文字列表現が取得できる。

>>> [token.surface for token in tokens]
['吾輩', 'は', '猫', 'で', 'ある']

読みがほしければ Token#reading で。

>>> [token.reading for token in tokens]
['ワガハイ', 'ハ', 'ネコ', 'デ', 'アル']

あと Janome には CLI も付属しているので手軽に形態素解析の結果が確認できる。 janome コマンドで CLI を起動して、日本語の文章を入力すると解析結果が表示される。

$ janome
吾輩は猫である
吾輩  名詞,代名詞,一般,*,*,*,吾輩,ワガハイ,ワガハイ
は 助詞,係助詞,*,*,*,*,は,ハ,ワ
猫 名詞,一般,*,*,*,*,猫,ネコ,ネコ
で 助動詞,*,*,*,特殊・ダ,連用形,だ,デ,デ
ある  助動詞,*,*,*,五段・ラ行アル,基本形,ある,アル,アル

とてもべんり。

Python: chardet でテキストファイルの文字コードを検出する

マルチバイト文字の含まれたテキストファイルを扱おうとすると、文字コードがまず問題になる。 そのファイルがいったい何でエンコードされているか分からないと、それを適切に扱うことは到底できない。 そんなとき使うと便利なのが、今回紹介する chardet というサードパーティ製のパッケージ。

今回の検証環境には Mac OS X を使った。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.11.1
BuildVersion:   15B42

インストール

まずは pip を使って chardet をインストールする。

$ pip install chardet

下準備

次に、文字コードを検出するのに使うテキストファイルを用意しよう。

$ cat << EOF > helloworld.txt
こんにちは、世界
EOF

上記のコマンドで作ったテキストファイルが何でエンコードされるかはシェルと端末の設定に依存する。 手元の環境では UTF-8 を使っている。

$ echo $LANG
ja_JP.UTF-8
$ nkf --guess helloworld.txt
UTF-8 (LF)

chardet を使う

それでは準備が整ったので chardet を使ってみよう。 まずは先ほど作ったテキストファイルをバイナリモードで読み込む。 この時点では読み込んだ内容は謎のバイト列だ。

$ python
>>> with open('helloworld.txt', mode='rb') as f:
...     binary = f.read()
...
>>> binary
b'\xe3\x81\x93\xe3\x82\x93\xe3\x81\xab\xe3\x81\xa1\xe3\x81\xaf\xe3\x80\x81\xe4\xb8\x96\xe7\x95\x8c\n'

次に chardet パッケージをインポートして detect() 関数に先ほど読み込んだバイト列を渡す。 これだけで chardet が何の文字コードを使ってエンコードされたテキストなのか推定してくれる。 結果は辞書で返ってきて、その中の 'encoding' が推定した文字コードで、'confidence' がその文字コードである確度を表している。

>>> import chardet
>>> chardet.detect(binary)
{'confidence': 0.99, 'encoding': 'utf-8'}

あとは推定された文字コードを使ってバイト列を Python のユニコード文字列にデコードすればいい。

>>> binary.decode('utf-8')
'こんにちは、世界\n'

念のため別の文字コードでも試しておこう。 nkf コマンドを使って EUC-JP でエンコードされたテキストファイルを作りなおす。

$ nkf -e helloworld.txt > helloworld_euc.txt
$ nkf --guess helloworld_euc.txt
EUC-JP (LF)

先ほどと同様に chardet で文字コードを推定する。

$ python
>>> with open('helloworld_euc.txt', mode='rb') as f:
...     binary = f.read()
...
>>> import chardet
>>> chardet.detect(binary)
{'encoding': 'EUC-JP', 'confidence': 0.99}

ばっちり。

バイナリファイルだった場合

読み込んでみたらテキストファイルではなく実はバイナリファイルでした、という場合もあるはず。 そのときの挙動も確認しておこう。 これはようするにテキストファイルでさえなければいいので '/dev/random' デバイスから適当にバイト列を読み込むことにする。

>>> with open('/dev/random', mode='rb') as f:
...     binary = f.read(32)
... 
>>> binary
b'm\x1b\x828k\xd2\xc6\xefl\xf0Np\x7f\xd9*H\xd2\xf3\xb4\xc9\xc1>g\xf8\x1eo\xfd\xbe\x18A\xd3$'

読み込んだバイト列の文字コードを chardet の detect() 関数で推定させる。 しかし、単なるランダムなバイト列なので文字コードが分かるはずもない。 その場合は 'encoding' が None になる。

>>> import chardet
>>> chardet.detect(binary)
{'confidence': 0.0, 'encoding': None}

つまり、読み込んだのがバイナリファイルっぽいか否かはここの値をチェックすれば分かりそうだ。

大きなファイルを扱う場合

先ほどの detect() 関数をとても大きなファイルに対して使うと推定に長い時間がかかるらしい。 そのため、少しずつバイト列を渡していってある程度確証が得られた時点で計算を打ち切ることができる API も用意されているようだ。

次のサンプルコードでは、その API を使っている。 UniversalDetector というクラスのインスタンスには feed() というメソッドがあり、これには複数回に分けてバイト列を渡すことができる。 十分な確度で推定が完了するとインスタンスのメンバ変数 done が真になるため、そこで計算を打ち切ることができる。 ただ、done フラグが立つには相当に大きなサイズが必要っぽいので、中途半端な大きさではふつうにファイルを最後まで読み終わって処理が完了するようだ。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from chardet.universaldetector import UniversalDetector


def main():
    detector = UniversalDetector()

    try:
        with open('helloworld.txt', mode='rb') as f:
            while True:
                binary = f.readline()
                if binary == b'':
                    # ファイルを最後まで読みきった
                    break

                detector.feed(binary)
                if detector.done:
                    # 十分な確度でエンコーディングが推定できた
                    break
    finally:
        detector.close()

    encoding_info = detector.result
    print(encoding_info)


if __name__ == '__main__':
    main()

上記を実行してみよう。 helloworld.txt はたった一行だけのテキストファイルなので当然 done は真にならずに終わる。

$ python detector.py 
{'encoding': 'utf-8', 'confidence': 0.99}

めでたしめでたし。