現在地: ホーム Dive Into Python 3

難易度: ♦♦♦♦♦

ケーススタディ: chardetをPython 3に移植する

言葉だよ言葉。俺たちはこいつらを口に出してりゃいいんだ。
Rosencrantz and Guildenstern are Dead

 

飛び込む

問題: ウェブ、メール、そして今までに作られたありとあらゆるコンピュータシステムにおける、文字化けの原因ナンバーワンは何でしょう? 答えは文字コードだ。文字列の章では、文字コードの歴史と、「すべてを支配する1つの文字コード」であるUnicodeの誕生について話した。しウェブページ上で二度と文字化けを見ずに済むようになるなら、私はUnicodeを愛すのだろうけれど、それには、すべてのオーサリングシステムが正確な文字コード情報を格納し、すべての転送プロトコルがUnicodeに対応し、テキストを扱うすべてのシステムが文字コードの変換時に完全な忠実さを持つ必要がある。

私はポニーも欲しいんだ。

ユニコードのポニー。

言ってみれば、ユニポニー。

私は文字コードの自動検出で我慢しておくよ。

文字コードの自動検出とは何か?

要するに、どの文字コードで符号化されたのか分からないバイト列を受け取って、その文字コードを推測することでテキストを読み込めるようにしようというわけだ。これは、復号鍵を知らない状態で暗号を解読しようとするのに似ている。

それって不可能じゃないの?

一般論としてはイエスだ。とはいえ、一部のエンコーディングは特定の言語に最適化されていて、そして言語というものは何らかの規則性を持っているものだ。文章のあちこちに現れる文字列もあれば、言葉として意味をなさない文字列もある。英語に堪能な人が新聞を開いて “txzqJv 2!dasd0a QqdKjvz” という文章を見つけたら、(文章自体はすべて英語の文字で構成されているけれども)これは英語の文章ではないと即座に認識できるだろう。この種の言語認識は、大量の「典型的」な文章の解析を行えば、統計的なアルゴリズムによってシミュレートすることができるので、文章が用いている言語をある程度推測することができる。

言い換えれば、文字コードの検出というのは、実際には言語の検出に、どの言語がどの文字コードを使う傾向にあるかという知識を組み合わせたものと言える。

そういうアルゴリズムは実際に存在するの?

実際のところ、イエスだ。主要なウェブブラウザはどれも文字コードの自動検出機能を持っている。というのも、ウェブは文字コードの情報を一切宣言していないページで満ちあふれているからだ。Mozilla Firefoxには、文字コードの自動検出を行うオープンソースのライブラリが組み込まれている私はこのライブラリをPython 2に移植し、それにchardetモジュールという名前を付けた。この章は、chardetモジュールをPython 2からPython 3へ移植する過程をステップバイステップで説明する。

chardetモジュールのご紹介

コードの移植に取りかかるまえに、それがどのように動作しているのかを理解してもらうのがいいだろう。この節は、コード自体を案内する短いガイドになっている。サイズが大きすぎるため、chardetライブラリをここに載せることはできないが、pypi.python.org/pypi/chardetからダウンロードすることができる。

検出アルゴリズムのメインエントリポイントはuniversaldetector.pyであり、そこにはUniversalDetectorというクラスが1つだけ定義さている(メインエントリポイントはchardet/__init__.pydetect関数だと思ったかもしれないが、実はそれはユーザの便宜のために用意された関数にすぎない。この関数は、UniversalDetectorオブジェクトを作成した上で呼び出し、その結果を返すという処理だけを行う)。

UniversalDetectorが扱う文字コードは5つのカテゴリに分けられる:

  1. バイトオーダーマーク (BOM) の付いたUTF-n。これにはUTF-8、ビッグエンディアンとリトルエンディアンのUTF-16、4種類のバイトオーダーのUTF-32が含まれる。
  2. エスケープを利用した文字コード。これは7ビットASCIIと完全な互換性をもち、非ASCII文字はエスケープシーケンスで開始される。例:ISO-2022-JP(日本語)とHZ-GB-2312(中国語)。
  3. マルチバイトの文字コード。個々の文字が可変個のバイトで表現される。例:Big5(中国語)、SHIFT_JIS (日本語)、EUC-KR(韓国語)、BOMのないUTF-8
  4. シングルバイトの文字コード。個々の文字が1バイトで表現される。例:KOI8-R(ロシア語)、windows-1255(ヘブライ語)、TIS-620(タイ語)。
  5. windows-1252。主にWindows上で用いられている文字コードで、文字コードのことを理解しようとしないマヌケな中間管理職が使っている。

BOMの付いたUTF-n

テキストがBOMで始まる場合は、そのテキストがUTF-8UTF-16UTF-32のどれかでエンコードされていると合理的に推測できる(さらにBOMはこれらのうちのどれであるかを教えてくれる。そもそもBOMはそのためのものだ)。この識別はUniversalDetectorの中でインラインで行われ、UniversalDetectorは、それ以降の処理を行わずに即座に結果を返す。

エスケープを利用した文字コード

エスケープを利用した文字コードであることを示すようなエスケープシーケンスがテキストの中に見つかった場合には、UniversalDetectorEscCharSetProberescprober.pyで定義されている)を作成し、そこにテキストを流し込む。

EscCharSetProberは、HZ-GB-2312ISO-2022-CNISO-2022-JPISO-2022-KRのモデル(escsm.pyで定義されている)に基づいた、一連の状態機械 (state machine) を作成する。EscCharSetProberは、これらの状態機械にテキストを1バイトずつ流し込んでいく。状態機械のどれかが文字コードを一意に特定する結果になったときは、EscCharSetProberは「文字コードを特定した」という結果を即座にUniversalDetectorに返し、UniversalDetectorはその結果を呼び出し元に返す。各々の状態機械は、その文字コードで解釈できないシーケンスに突き当たった時点で脱落させられ、以後の処理は残りの状態機械だけで続けられる。

マルチバイトの文字コード

BOMがないときは、UniversalDetectorはテキストに0x80〜0xFFのバイトが含まれていないかをチェックする。含まれている場合は、UniversalDetectorはマルチバイト文字列と、シングルバイト文字列と、最後の手段であるwindows-1252を検出するための一連の「調査器 (prober)」を作成する。

マルチバイトエンコーディングの調査器MBCSGroupProbermbcsgroupprober.pyで定義されている)は、実際には各々のマルチバイト文字コード(Big5GB2312EUC-TWEUC-KREUC-JPSHIFT_JISUTF-8)の調査器からなるグループを管理するための単なるまとめ役に過ぎない。MBCSGroupProberは、これら文字コード固有の調査器それぞれにテキストを流し込み、その結果をチェックする。調査器が、解釈できないバイト列を見つけたと報告したら、その調査器は以後の処理から脱落させられる(つまり、例えば、以後のUniversalDetector.feed()の呼び出しはその調査器をスキップする)。調査器の一つが、これでまず間違いないという文字コードを検出したら、MBCSGroupProberはその結果をUniversalDetectorに報告し、さらにUniversalDetectorはこれを呼び出し元に報告する。

マルチバイト文字コードの調査器のほとんどはMultiByteCharSetProbermbcharsetprober.pyで定義されている)を継承している。これらの調査器は適切な状態機械と分布解析器をセットしているだけで、残りの仕事はMultiByteCharSetProberがやっている。MultiByteCharSetProberは、テキストを文字コード固有の状態機械に1文字ずつ流し込んでいき、「確実にこの文字コードが使われている」ないし「この文字コードでは絶対にない」ということを指し示すバイトシーケンスを探す。それと同時にMultiByteCharSetProberは、テキストを文字コード固有の分布解析器にも流し込んでいく。

分布解析器(各々はchardistribution.pyで定義されている)は、どの文字が頻繁に使われるのかについての言語固有のモデルを持っている。MultiByteCharSetProberが分布解析器に十分なテキストを流し込むと、「よく使われる文字」の出現回数、テキストの総文字数、言語固有の分布比に基づいて信頼度を計算する。信頼度が十分に高い場合、MultiByteCharSetProberはその結果をMBCSGroupProberに返し、MBCSGroupProberはそれをUniversalDetectorに返し、UniversalDetectorはそれを呼び出し元に返す。

日本語の場合はもっと難しい。1文字の分布解析はEUC-JPSHIFT_JISを区別するのには不十分なことがあるので、SJISProbersjisprober.pyで定義されている)は2文字ごとの分布解析も行っている。SJISContextAnalysisEUCJPContextAnalysis(両者はjpcntx.pyで定義されていて共通のJapaneseContextAnalysisクラスを継承している)は、テキストに含まれているひらがなの頻度をチェックする。十分な量のテキストが処理されると、その信頼度がSJISProberに返され、SJISProberは両方の分析器をチェックして、高いほうの信頼度をMBCSGroupProberに返す。

シングルバイトの文字コード

シングルバイトの文字コード調査器SBCSGroupProbersbcsgroupprober.pyで定義されている)も、他の調査器のまとめ役にすぎない。束ねられている各々の調査器は、特定の言語・文字コードの組み合わせに対応している:windows-1251KOI8-RISO-8859-5MacCyrillicIBM855IBM866 (ロシア語)、ISO-8859-7windows-1253 (ギリシャ語)、ISO-8859-5windows-1251 (ブルガリア語)、ISO-8859-2windows-1250(ハンガリー語); TIS-620 (タイ語); windows-1255ISO-8859-8(ヘブライ語)

SBCSGroupProberは、テキストをこれらの文字コード+言語に固有の調査器に入力し、その結果をチェックする。これらの調査器はすべて単一のクラスSingleByteCharSetProbersbcharsetprober.pyで定義されている)として実装されていて、このクラスは引数として言語モデルを受け取る。この言語モデルは、異なる2文字の組み合わせが典型的なテキストにおいてどのくらいの頻度で出現するのかを定義している。SingleByteCharSetProberはテキストを処理し、「よく使われる2文字の組み合わせ」が文章にいくつ現れているかを数える。十分なテキストが処理されると、SingleByteCharSetProberは、よく使われる2文字組の出現回数と、テキストの総文字数と、言語固有の分布比に基づいて信頼度の計算を行う。

ヘブライ語は特別なケースとして扱われる。2文字組の分布解析に基づいてテキストがヘブライ語だと思われる場合は、HebrewProberhebrewprober.pyで定義されている)を使って、ビジュアルヘブライ(ソーステキストが行ごとに「逆向き」に格納されていて、そのまま表示すれば右から左に読めるようになっている)とロジカルヘブライ(ソーステキストは読む順番で格納され、クライアントによって右から左に表示される)の判別を行う。いくつかの文字は単語の中間に現れるのか単語の末尾に現れるのかによって異なる符号化処理がなされるので、これを使えば元のテキストの方向をほぼ確実に識別することができるのだ。その上で、適切な文字コード(ロジカルヘブライ用のwindows-1255またはビジュアルヘブライ用のISO-8859-8)を返す。

windows-1252

UniversalDetectorが0x80〜0xFFのバイトをテキストから検出したにもかかわらず、他のマルチバイトやシングルバイトの調査器が一つも信頼できる結果を返さなかった場合は、windows-1252でエンコードされた英文テキストかどうかを判別するためにLatin1Proberlatin1prober.pyで定義されている)を作成する。この検出は本質的に信頼できないものでしかない。というのも、英語の文字をエンコードする方法は大抵の文字コードで同じだからだ。windows-1252を識別するには、スマートクォート、カールしたアポストロフィ、コピーライト記号、などなどの一般的に使われる記号に頼るしかない。Latin1Proberは、もっと正確な調査器にできるだけ勝たせてあげるために、自分の信頼度を自動的に減少させるようになっている。

2to3を実行する

これより、chardetモジュールをPython 2からPython 3に移植する。python 3には2to3と呼ばれるユーティリティスクリプトが付属している。このスクリプトは、実際のPython 2のソースコードを受け取って、Python 3で動くように可能な限り自動変換してくれるものだ。変換が容易であるケース(関数の名前が変更されたとか、他のモジュールに移動されたなど)もあれば、かなり複雑なものになるケースもある。このツールがどこまでできるのかを知るには、Appendixの2to3を使ってコードをPython 3に移植するを参照してほしい。この章は、2to3chardetパッケージに対して実行することから始めるが、後で分かるように、この自動化されたツールが魔法を披露したあとでも、私たちがやらなければならない仕事はまだ大量に存在する。

chardetパッケージは複数のファイルに分割されていて、それらのファイルはすべて同じディレクトリに納められている。2to3は、簡単に複数のファイルを一度で変換できるようになっている。ディレクトリをコマンドライン引数として渡すだけで、2to3は各ファイルを次々に変換してくれるのだ。

C:\home\chardet> python c:\Python30\Tools\Scripts\2to3.py -w chardet\
RefactoringTool: Skipping implicit fixer: buffer
RefactoringTool: Skipping implicit fixer: idioms
RefactoringTool: Skipping implicit fixer: set_literal
RefactoringTool: Skipping implicit fixer: ws_comma
--- chardet\__init__.py (original)
+++ chardet\__init__.py (refactored)
@@ -18,7 +18,7 @@
 __version__ = "1.0.1"

 def detect(aBuf):
-    import universaldetector
+    from . import universaldetector
     u = universaldetector.UniversalDetector()
     u.reset()
     u.feed(aBuf)
--- chardet\big5prober.py (original)
+++ chardet\big5prober.py (refactored)
@@ -25,10 +25,10 @@
 # 02110-1301  USA
 ######################### END LICENSE BLOCK #########################

-from mbcharsetprober import MultiByteCharSetProber
-from codingstatemachine import CodingStateMachine
-from chardistribution import Big5DistributionAnalysis
-from mbcssm import Big5SMModel
+from .mbcharsetprober import MultiByteCharSetProber
+from .codingstatemachine import CodingStateMachine
+from .chardistribution import Big5DistributionAnalysis
+from .mbcssm import Big5SMModel

 class Big5Prober(MultiByteCharSetProber):
     def __init__(self):
--- chardet\chardistribution.py (original)
+++ chardet\chardistribution.py (refactored)
@@ -25,12 +25,12 @@
 # 02110-1301  USA
 ######################### END LICENSE BLOCK #########################

-import constants
-from euctwfreq import EUCTWCharToFreqOrder, EUCTW_TABLE_SIZE, EUCTW_TYPICAL_DISTRIBUTION_RATIO
-from euckrfreq import EUCKRCharToFreqOrder, EUCKR_TABLE_SIZE, EUCKR_TYPICAL_DISTRIBUTION_RATIO
-from gb2312freq import GB2312CharToFreqOrder, GB2312_TABLE_SIZE, GB2312_TYPICAL_DISTRIBUTION_RATIO
-from big5freq import Big5CharToFreqOrder, BIG5_TABLE_SIZE, BIG5_TYPICAL_DISTRIBUTION_RATIO
-from jisfreq import JISCharToFreqOrder, JIS_TABLE_SIZE, JIS_TYPICAL_DISTRIBUTION_RATIO
+from . import constants
+from .euctwfreq import EUCTWCharToFreqOrder, EUCTW_TABLE_SIZE, EUCTW_TYPICAL_DISTRIBUTION_RATIO
+from .euckrfreq import EUCKRCharToFreqOrder, EUCKR_TABLE_SIZE, EUCKR_TYPICAL_DISTRIBUTION_RATIO
+from .gb2312freq import GB2312CharToFreqOrder, GB2312_TABLE_SIZE, GB2312_TYPICAL_DISTRIBUTION_RATIO
+from .big5freq import Big5CharToFreqOrder, BIG5_TABLE_SIZE, BIG5_TYPICAL_DISTRIBUTION_RATIO
+from .jisfreq import JISCharToFreqOrder, JIS_TABLE_SIZE, JIS_TYPICAL_DISTRIBUTION_RATIO

 ENOUGH_DATA_THRESHOLD = 1024
 SURE_YES = 0.99
.
.
. (it goes on like this for a while)
.
.
RefactoringTool: Files that were modified:
RefactoringTool: chardet\__init__.py
RefactoringTool: chardet\big5prober.py
RefactoringTool: chardet\chardistribution.py
RefactoringTool: chardet\charsetgroupprober.py
RefactoringTool: chardet\codingstatemachine.py
RefactoringTool: chardet\constants.py
RefactoringTool: chardet\escprober.py
RefactoringTool: chardet\escsm.py
RefactoringTool: chardet\eucjpprober.py
RefactoringTool: chardet\euckrprober.py
RefactoringTool: chardet\euctwprober.py
RefactoringTool: chardet\gb2312prober.py
RefactoringTool: chardet\hebrewprober.py
RefactoringTool: chardet\jpcntx.py
RefactoringTool: chardet\langbulgarianmodel.py
RefactoringTool: chardet\langcyrillicmodel.py
RefactoringTool: chardet\langgreekmodel.py
RefactoringTool: chardet\langhebrewmodel.py
RefactoringTool: chardet\langhungarianmodel.py
RefactoringTool: chardet\langthaimodel.py
RefactoringTool: chardet\latin1prober.py
RefactoringTool: chardet\mbcharsetprober.py
RefactoringTool: chardet\mbcsgroupprober.py
RefactoringTool: chardet\mbcssm.py
RefactoringTool: chardet\sbcharsetprober.py
RefactoringTool: chardet\sbcsgroupprober.py
RefactoringTool: chardet\sjisprober.py
RefactoringTool: chardet\universaldetector.py
RefactoringTool: chardet\utf8prober.py

今度は、自動テストスクリプトであるtest.pyに対して2to3を実行しよう。

C:\home\chardet> python c:\Python30\Tools\Scripts\2to3.py -w test.py
RefactoringTool: Skipping implicit fixer: buffer
RefactoringTool: Skipping implicit fixer: idioms
RefactoringTool: Skipping implicit fixer: set_literal
RefactoringTool: Skipping implicit fixer: ws_comma
--- test.py (original)
+++ test.py (refactored)
@@ -4,7 +4,7 @@
 count = 0
 u = UniversalDetector()
 for f in glob.glob(sys.argv[1]):
-    print f.ljust(60),
+    print(f.ljust(60), end=' ')
     u.reset()
     for line in file(f, 'rb'):
         u.feed(line)
@@ -12,8 +12,8 @@
     u.close()
     result = u.result
     if result['encoding']:
-        print result['encoding'], 'with confidence', result['confidence']
+        print(result['encoding'], 'with confidence', result['confidence'])
     else:
-        print '******** no result'
+        print('******** no result')
     count += 1
-print count, 'tests'
+print(count, 'tests')
RefactoringTool: Files that were modified:
RefactoringTool: test.py

それほど大変なものではなかった。いくつかのインポート文とprint文を変換しただけだ。ところで、そもそもこのインポート文の何がまずかったのだろう。それに答えるには、chardetモジュールがどのように複数のファイルに分割されているのかを知らなければならない。

マルチファイルモジュールの話に少し脱線

chardetは複数のファイルで構成されるモジュールだ。これをchardet.pyという一つのファイルにまとめることもできたのだが、そうしなかった。その代わりに、私はchardetという名前のディレクトリを作成し、そのディレクトリの中に__init__.pyというファイルを置いた。ディレクトリの中に__init__.pyというファイルを見つけると、Pythonはそのディレクトリの中にあるファイル全体が1つのモジュールを構成しているものと解釈する。そのディレクトリ名がモジュールの名前になる。ディレクトリの中にあるファイルは、同じディレクトリにある他のファイルを参照できるし、さらにサブディレクトリの中にあるファイルを参照することもできる(詳細はすぐに述べる)。しかしそのファイルの集まり全体は、ほかのPythonコードには単一のモジュールとして見える。あたかもすべての関数とクラスが単一の.pyファイルの中にあるかのように見えるのだ。

この__init__.pyファイルは何をするのだろうか? 何もしないかもしれない。すべてのことをするのかもしれない。その中間かもしれない。__init__.pyファイルは一切何も定義しなくてもいい。文字通り空っぽのファイルであってもいいし、メインのエントリーポイントになる関数を定義するために使ってもいいし、全ての関数を入れてもいい。

__init__.pyファイルを含むディレクトリは常にマルチファイルモジュールとして扱われる。__init__.pyファイルがなければ、そのディレクトリは、互いに関連が無い.pyファイルが入ったディレクトリとして扱われる。

実際にどのように動作するのかを見てみよう。

>>> import chardet
>>> dir(chardet)             
['__builtins__', '__doc__', '__file__', '__name__',
 '__package__', '__path__', '__version__', 'detect']
>>> chardet                  
<module 'chardet' from 'C:\Python31\lib\site-packages\chardet\__init__.py'>
  1. ふつうのクラス属性を除けば、chardetモジュールの中にあるものはdetect()関数だけだ。
  2. これはchardetモジュールが単一のファイルではないことを示す1つ目の証拠だ: この「モジュール」はchardet/ディレクトリにある__init__.pyファイルとして示されている。

__init__.py ファイルの中を覗いてみよう。

def detect(aBuf):                              
    from . import universaldetector            
    u = universaldetector.UniversalDetector()
    u.reset()
    u.feed(aBuf)
    u.close()
    return u.result
  1. この__init__.pyファイルはdetect()関数を定義している。この関数は、chardetライブラリへのメインのエントリポイントだ。
  2. しかし、このdetect()関数には中身がほとんどない! 実のところ、この関数が実際にやっているのは、universaldetectorモジュールをインポートして、使い始めることだけだ。しかし、universaldetectorはどこで定義されているのだろうか?

その答えはこの奇妙なimport文の中にある:

from . import universaldetector

これを翻訳すると、「universaldetectorモジュールをインポートしてほしい。そのモジュールは私と同じディレクトリにある」という意味になる。ここでの「私」とは、chardet/__init__.pyファイルのことだ。このインポートは相対インポートと呼ばれている。これは、一つのモジュールを構成している複数のファイルが互いを参照する方式の一つで、こうすればあなたのimport検索パスにインストールされているかもしれない他のモジュールとの名前の衝突を心配しなくてもよくなる。このimport文は、chardet/ディレクトリの中にあるuniversaldetectorモジュールだけを探す。

これらの2つの概念、つまり__init__.pyと相対インポートは、自分のモジュールを望む限りいくつのピースにでも分解できることを意味している。chardetモジュールは36個の.pyファイルから構成されている。36個だ! とはいえ、このモジュールを使うのに必要な処理はimport chardetだけであり、それだけでメインのchardet.detect()関数を呼び出すことができるようになる。モジュールを使う側からは分からないことだが、このdetect()関数は実際にはchardet/__init__.pyファイルで定義されている。また、これも気がつかないことだが、detect()関数は相対インポートを使ってchardet/universaldetector.pyで定義されているクラスを参照している。このファイルはさらに別の5つのファイルを相対インポートしていて、これらのファイルはすべてchardet/ディレクトリにおさまっている。

Pythonで大きなライブラリを書いているなら(というよりも書いているライブラリが大きくなってきたら)、時間を割いて、そのライブラリをマルチファイルモジュールへとリファクタリングするといい。これはPythonが得意なことの1つなので、ぜひ活用しよう。

2to3にはできないことを修正する

False is invalid syntax

さて実際のテストを行おう。自動テストフレームワークを実行してテストスイートを検証するのだ。このテストスイートは考え得るすべてのコードパスを網羅するように設計されているので、これは私たちが移植したコードのどこにもバグが潜んでいないことを確認するための良い方法だ。

C:\home\chardet> python test.py tests\*\*
Traceback (most recent call last):
  File "test.py", line 1, in <module>
    from chardet.universaldetector import UniversalDetector
  File "C:\home\chardet\chardet\universaldetector.py", line 51
    self.done = constants.False
                              ^
SyntaxError: invalid syntax

うーん、これはちょっとした障害だ。Python 3ではFalseが予約語になったので、これを変数名として使うことはできない。これが定義されている場所を見るために、constants.pyを見てみよう。以下は2to3によって変更される前の、元のバージョンのconstants.pyだ。

import __builtin__
if not hasattr(__builtin__, 'False'):
    False = 0
    True = 1
else:
    False = __builtin__.False
    True = __builtin__.True

このコードは、ライブラリがPython 2の古いバージョンでも動くようにするためのものだ。Python 2.3以前では、Pythonは組み込みのbool型を持っていなかった。このコードは組み込み定数TrueFalseが存在しないことを検出し、必要であればそれらを定義する。

しかしながら、Python 3には必ずbool型があるので、この部分のコードはすべて要らなくなる。最も簡単な解決方法は、ライブラリの中でconstants.Trueconstants.Falseを使っている部分を、それぞれTrueFalseで書き換えて、この死んだコードをconstants.pyから取り除くことだ。

よって、universaldetector.pyの次の行は:

self.done = constants.False

こうなる

self.done = False

うむ。こんな感じで十分かな。コードは以前よりも短くて読みやすいものになっている。

No module named constants

もう一度test.pyを実行して、どこまで行けるか見てみよう。

C:\home\chardet> python test.py tests\*\*
Traceback (most recent call last):
  File "test.py", line 1, in <module>
    from chardet.universaldetector import UniversalDetector
  File "C:\home\chardet\chardet\universaldetector.py", line 29, in <module>
    import constants, sys
ImportError: No module named constants

何だって? constantsという名前のモジュールがない? 言うまでもなくconstantsという名前のモジュールは存在している。ほら、確かにchardet/constants.pyにあるじゃないか。

2to3スクリプトがこれらのインポート文を修正したときのことを覚えているだろうか? このライブラリは相対インポートを多用していたが(つまり、同じライブラリの中にある他のモジュールをインポートするモジュールがたくさんあったが)、相対インポートの仕組みはPython 3で変更されている。Python2では、import constantsと書けば、まずはchardet/ディレクトリからconstantsモジュールの探索が開始された。しかし、Python 3では、すべてのインポート文はデフォルトでは絶対インポートだと解釈される(従って、sys.path上にあるモジュールしか検索されない)。Python 3で相対インポートを使いたければ、そのことを明示しなければならない。

from . import constants

しかし待って欲しい。これらは2to3スクリプトがやってくれるはずではなかったのか? そう、2to3はこれをやってくれるのだが、このインポート文では2種類のインポートを1行で行っている。ライブラリ内のconstantsモジュールを相対インポートしている一方で、標準モジュールとしてインストールさているsysを絶対インポートしているのだ。Python 2ではこの2つのインポート文を1行にまとめて書くことができたのだが、Python 3ではそれが許されていない。そして、2to3スクリプトはこのインポート文を2行に分割できるほど賢くないのだ。

解決方法はこのインポート文を手作業で分割することだ。したがって、次の2つが1つになっているインポート文は:

import constants, sys

2つの別々のインポート文にしなくてはいけない:

from . import constants
import sys

これと同じ問題は、chardetライブラリのあらゆる部分に見られる。ある部分では “import constants, sys” としているし、他の部分では “import constants, re” としている。修正方法はすべて同じだ。手作業でインポート文を2行に分割し、1つは相対インポート、もう一つは絶対インポートにすればいい。

次へ進もう!

Name 'file' is not defined

もう一度、test.pyを走らせてテストケースを実行してみよう……

C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml
Traceback (most recent call last):
  File "test.py", line 9, in <module>
    for line in file(f, 'rb'):
NameError: name 'file' is not defined

このエラーには驚かされた。なぜなら、私はこのイディオムをずっと昔から使いつづけてきたからだ。Python 2では、グローバルのfile()関数はopen()関数のエイリアスであり、これはテキストファイルを読み込み用に開くための標準的な方法だった。Python 3では、グローバルのfile()関数はもはや存在せず、open()関数だけしかない。

したがって、file()関数が見つからないという問題の最も簡単な解決方法は、file()の代わりにopen()関数を呼び出すことだ。

for line in open(f, 'rb'):

これに関して言うべきことはこれだけだ。

Can’t use a string pattern on a bytes-like object

面白くなり始めてきた。「面白い」っていうのは「うんざりするほどややこしい」という意味だけどね。

C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml
Traceback (most recent call last):
  File "test.py", line 10, in <module>
    u.feed(line)
  File "C:\home\chardet\chardet\universaldetector.py", line 98, in feed
    if self._highBitDetector.search(aBuf):
TypeError: can't use a string pattern on a bytes-like object

これをデバッグするために、self._highBitDetectorが何なのかを見てみよう。これはUniversalDetectorクラスの__init__メソッドで定義されている:

class UniversalDetector:
    def __init__(self):
        self._highBitDetector = re.compile(r'[\x80-\xFF]')

これは128–255 (0x80–0xFF) の範囲にある非ASCII文字を見つけるための正規表現をプリコンパイルしている。いや、待って、その言い方は正しくない。もっと正確な言葉を使おう。このパターンは、128–255の範囲にある非ASCIIバイトを見つけ出すためのものだ。

そして、そこに問題がひそんでいる。

Python 2では、文字列はバイトの配列であり、文字コードはそれとは別に追跡されていた。Python 2に文字コードを追跡させたいときは、代わりにUnicode文字列 (u'') を使わなければいけなかった。しかしPython 3では、文字列は常にPython 2がUnicode文字列と呼んでいたものだ。つまり、(おそらく可変バイト長の)Unicode文字の配列だ。この正規表現は文字列のパターンで定義されているので、これは文字列の検索だけに使用できる。しかし私たちが検索しているのは文字列ではなく、バイト列だ。トレースバックを見ると、このエラーはuniversaldetector.pyで発生していることが分かる:

def feed(self, aBuf):
    .
    .
    .
    if self._mInputState == ePureAscii:
        if self._highBitDetector.search(aBuf):

aBufというのは何だろう? UniversalDetector.feed()を呼び出しているところまでバックトラックしてみよう。これを呼び出している場所の1つは自動テストスクリプトのtest.pyだ。

u = UniversalDetector()
.
.
.
for line in open(f, 'rb'):
    u.feed(line)

ここで答えを見つけた。UniversalDetector.feed()メソッドでは、aBufはディスクから読み込んだファイルの1つの行だ。ファイルを開くときのパラメータを注意深く見てほしい: 'rb'だ。'r'は「読み込み」を表す。オーケー、ここではファイルを読み込もうとしているわけだ。あっ、でも'b'は「バイナリ」を表すものだ。もし'b'フラグが無かったら、このforループはファイルを1行ずつ読み込んで、各々の行をシステムのデフォルト文字コードに従って文字列(Unicode文字の配列)に変換することになる。しかし、'b'フラグがあるときは、このforループはファイルを1行ずつ読み込んで、各々の行を、ファイルに現れる通りにバイトの配列として格納する。そのバイト列はUniversalDetector.feed()に渡され、最終的には0x80-0xFFの「文字」を探すためのプリコンパイルされた正規表現self._highBitDetectorに渡される。しかし、渡されたのは文字ではなく、バイトだ。これではダメだ。

私たちがこの正規表現に検索させたいのは文字の配列ではなく、バイトの配列だ。

これに気がつきさえすれば、解決方法は難しくない。文字列を使って定義した正規表現は文字列を検索できる。バイト列を使って定義した正規表現はバイト列を検索できる。バイト列のパターンを定義するには、正規表現を定義するのに使う引数の型をバイト列に変更するだけでいい(同じ問題がすぐ下の行にもある)。

  class UniversalDetector:
      def __init__(self):
-         self._highBitDetector = re.compile(r'[\x80-\xFF]')
-         self._escDetector = re.compile(r'(\033|~{)')
+         self._highBitDetector = re.compile(b'[\x80-\xFF]')
+         self._escDetector = re.compile(b'(\033|~{)')
          self._mEscCharSetProber = None
          self._mCharSetProbers = []
          self.reset()

他にreモジュールを使っている部分が無いかコードベース全体を検索してみると、charsetprober.pyの中に2ヶ所あることが分かった。ここでもまた、文字列の正規表現を定義しながら、それをバイト列であるaBufに対して実行している。解決方法は同じだ。正規表現パターンをバイト列として定義すればいい。

  class CharSetProber:
      .
      .
      .
      def filter_high_bit_only(self, aBuf):
-         aBuf = re.sub(r'([\x00-\x7F])+', ' ', aBuf)
+         aBuf = re.sub(b'([\x00-\x7F])+', b' ', aBuf)
          return aBuf

      def filter_without_english_letters(self, aBuf):
-         aBuf = re.sub(r'([A-Za-z])+', ' ', aBuf)
+         aBuf = re.sub(b'([A-Za-z])+', b' ', aBuf)
          return aBuf

Can't convert 'bytes' object to str implicitly

ますます奇妙だ……

C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml
Traceback (most recent call last):
  File "test.py", line 10, in <module>
    u.feed(line)
  File "C:\home\chardet\chardet\universaldetector.py", line 100, in feed
    elif (self._mInputState == ePureAscii) and self._escDetector.search(self._mLastChar + aBuf):
TypeError: Can't convert 'bytes' object to str implicitly

ここにはコーディングスタイルとPythonインタプリタの不幸な衝突がある。TypeErrorはこの行のどの部分にでも起こりうるのだが、トレースバックはその正確な場所を教えてはくれない。例外が発生しているのは1つ目の条件文かもしれないし、2つ目の条件文かもしれないのだが、どちらにしても同じトレースバックが出力される。原因を絞り込むために、この行を次のように途中で分けるのがよいだろう:

elif (self._mInputState == ePureAscii) and \
    self._escDetector.search(self._mLastChar + aBuf):

そして、テストを再実行する:

C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml
Traceback (most recent call last):
  File "test.py", line 10, in <module>
    u.feed(line)
  File "C:\home\chardet\chardet\universaldetector.py", line 101, in feed
    self._escDetector.search(self._mLastChar + aBuf):
TypeError: Can't convert 'bytes' object to str implicitly

なるほど! 問題は1つ目の条件 (self._mInputState == ePureAscii) ではなく、2つ目の条件にあったのだ。だとすると、何がTypeErrorを引きおこしたのだろうか? ここで、想定していない型の値をsearch()メソッドに渡したからだ、と考えた人もいるかもしれない。しかし、それだとこのトレースバックは出てこない。Pythonの関数はどんな値でも引数に取れるので、正しい個数の引数を渡しさえすれば、その関数は実行される。想定していない型の値を渡したら関数はクラッシュするかもしれないが、そうなったとしても、エラーが発生した場所としてトレースバックが指し示すのはその関数の内部のどこかだ。このトレースバックは、search()メソッドを実行するところまで処理が進まなかったことを示している。従って、問題はこの+演算子の部分にあることになる。ここでsearch()メソッドに渡す値を構築するときにエラーが起きたのだ。

私たちは以前のデバッグ作業から、aBufがバイト列であることを知っている。では、self._mLastCharは何だろう? これはreset()メソッドで定義されているインスタンス変数だ。このメソッドは実のところ__init__()メソッドから呼び出されている。

class UniversalDetector:
    def __init__(self):
        self._highBitDetector = re.compile(b'[\x80-\xFF]')
        self._escDetector = re.compile(b'(\033|~{)')
        self._mEscCharSetProber = None
        self._mCharSetProbers = []
        self.reset()

    def reset(self):
        self.result = {'encoding': None, 'confidence': 0.0}
        self.done = False
        self._mStart = True
        self._mGotData = False
        self._mInputState = ePureAscii
        self._mLastChar = ''

もう答えは私たちの手の中にある。お分かりだろうか? self._mLastCharは文字列であるが、aBufはバイト列だ。そして文字列をバイト列と連結することはできない — たとえそれが長さ0の文字列であったとしてもだ。

それでself._mLastCharは一体何なのか? feed()メソッドの、トレースバックが起きた場所から数行下だ。

if self._mInputState == ePureAscii:
    if self._highBitDetector.search(aBuf):
        self._mInputState = eHighbyte
    elif (self._mInputState == ePureAscii) and \
            self._escDetector.search(self._mLastChar + aBuf):
        self._mInputState = eEscAscii

self._mLastChar = aBuf[-1]

呼び出し元の関数は、一度に数バイトずつ渡しながらfeed()メソッドを何度も呼び出す。このメソッドは与えられたバイト(aBufとして渡される)を処理し、次回の呼び出しで必要になったときのために最後のバイトをself._mLastCharに保存しておく(マルチバイトのエンコーディングでは、最初に一文字を構成するバイト列の半分だけがfeed()メソッドに渡され、後でもう半分が渡されるということがあるからだ)。しかし、aBufは今は文字列ではなくバイト列なので、self._mLastCharも同様にバイト列である必要がある。したがって:

  def reset(self):
      .
      .
      .
-     self._mLastChar = ''
+     self._mLastChar = b''

コードベース全体から“mLastChar”を検索すると、似たような問題がmbcharsetprober.pyに現れるが、ここでは最後の文字だけではなく、最後の2文字を追跡している。そして、このMultiByteCharSetProberクラスは最後の2文字を1文字ごとに分けてリストとして保存している。Python 3では、これは実際には文字を追跡するのではなくバイトを追跡しているので、整数のリストを使う必要がある(バイトは単なる0-255の整数だ)。

  class MultiByteCharSetProber(CharSetProber):
      def __init__(self):
          CharSetProber.__init__(self)
          self._mDistributionAnalyzer = None
          self._mCodingSM = None
-         self._mLastChar = ['\x00', '\x00']
+         self._mLastChar = [0, 0]

      def reset(self):
          CharSetProber.reset(self)
          if self._mCodingSM:
              self._mCodingSM.reset()
          if self._mDistributionAnalyzer:
              self._mDistributionAnalyzer.reset()
-         self._mLastChar = ['\x00', '\x00']
+         self._mLastChar = [0, 0]

Unsupported operand type(s) for +: 'int' and 'bytes'

良い知らせと悪い知らせがある。良い知らせは、着実に作業は進んでいるということであり……

C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml
Traceback (most recent call last):
  File "test.py", line 10, in <module>
    u.feed(line)
  File "C:\home\chardet\chardet\universaldetector.py", line 101, in feed
    self._escDetector.search(self._mLastChar + aBuf):
TypeError: unsupported operand type(s) for +: 'int' and 'bytes'

……悪い知らせは、これがちっとも進んでいるとは感じられないことだ。

しかし、ちゃんと前に進んでいるのだ! 本当だよ! トレースバックは同じコード行を指し示しているけれども、これは前のエラーとは違うものだ。だから一歩前進だ! それで、今度の問題は何なのだろうか? さっき見たときは、このコードはintとバイト列 (bytes) を連結させるなんてことはしていなかった。実際、つい先ほど多くの時間を費やしてself._mLastCharがバイト列になるように修正したばかりだ。それがなぜintになってしまったのだろうか?

答えはコードの前方の行ではなく、後方の行にある。

if self._mInputState == ePureAscii:
    if self._highBitDetector.search(aBuf):
        self._mInputState = eHighbyte
    elif (self._mInputState == ePureAscii) and \
            self._escDetector.search(self._mLastChar + aBuf):
        self._mInputState = eEscAscii

self._mLastChar = aBuf[-1]

このエラーは初めてfeed()メソッドが呼び出されたときには発生しない。エラーは、aBufの最後のバイトがself._mLastCharに代入されたあとの2回目の呼び出しで発生するのだ。そこにどんな問題があるのだろう? 実は、バイト列から要素を一つだけ取り出すと、バイト列ではなく整数が得られるのだ。その違いを見るために、対話シェルで私についてきてほしい。

>>> aBuf = b'\xEF\xBB\xBF'         
>>> len(aBuf)
3
>>> mLastChar = aBuf[-1]
>>> mLastChar                      
191
>>> type(mLastChar)                
<class 'int'>
>>> mLastChar + aBuf               
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'bytes'
>>> mLastChar = aBuf[-1:]          
>>> mLastChar
b'\xbf'
>>> mLastChar + aBuf               
b'\xbf\xef\xbb\xbf'
  1. 長さ3のバイト列を定義する。
  2. バイト列の最後の要素は191だ。
  3. これは整数だ。
  4. 整数とバイト列の連結は機能しない。今あなたは、universaldetector.pyで見つけたエラーを再現したのだ。
  5. これがバグを修正する方法だ。つまり、バイト列の最後の要素を取る代わりに、リストのスライスを使って最後の要素だけからなる新しいバイト列を作成するのだ。そのためには、最後の要素から始めてバイト列の終わりまでを切り出せばいい。これでmLastCharは長さ1のバイト列になった。
  6. 長さ1のバイト列と長さ3のバイト列を連結させれば、長さ4の新しいバイト列が返される。

universaldetector.pyfeed()メソッドが呼び出される回数に関わり無く、ちゃんと実行されるようにするためには、self._mLastCharの初期値を長さ0のバイト列に設定しておいてこの値がバイト列のまま保たれるようにする必要があるのだ

              self._escDetector.search(self._mLastChar + aBuf):
          self._mInputState = eEscAscii

- self._mLastChar = aBuf[-1]
+ self._mLastChar = aBuf[-1:]

ord() expected string of length 1, but int found

もう疲れてしまったかな? ゴールはもうすぐだ……

C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml                       ascii with confidence 1.0
tests\Big5\0804.blogspot.com.xml
Traceback (most recent call last):
  File "test.py", line 10, in <module>
    u.feed(line)
  File "C:\home\chardet\chardet\universaldetector.py", line 116, in feed
    if prober.feed(aBuf) == constants.eFoundIt:
  File "C:\home\chardet\chardet\charsetgroupprober.py", line 60, in feed
    st = prober.feed(aBuf)
  File "C:\home\chardet\chardet\utf8prober.py", line 53, in feed
    codingState = self._mCodingSM.next_state(c)
  File "C:\home\chardet\chardet\codingstatemachine.py", line 43, in next_state
    byteCls = self._mModel['classTable'][ord(c)]
TypeError: ord() expected string of length 1, but int found

オーケー、つまりcint型だけど、ord()関数は1文字の文字列しか受け取れないというわけだ。ごもっとも。cはどこで定義されているのだろうか?

# codingstatemachine.py
def next_state(self, c):
    # for each byte we get its class
    # if it is first byte, we also get byte length
    byteCls = self._mModel['classTable'][ord(c)]

ここからは何も分からない。cは関数に渡されているだけだ。呼び出しスタックをさかのぼろう。

# utf8prober.py
def feed(self, aBuf):
    for c in aBuf:
        codingState = self._mCodingSM.next_state(c)

分かったかな? Python 2では、aBufは文字列だったので、cは1文字の文字列だった(文字列をイテレートすると文字が一つずつ取り出されるからだ)。しかし、ここではaBufはバイト列になっているので、cは1文字の文字列ではなくintだ。言い換えると、cはすでにintなので、ord()関数を呼び出す必要はないということだ!

したがってこうすればいい:

  def next_state(self, c):
      # for each byte we get its class
      # if it is first byte, we also get byte length
-     byteCls = self._mModel['classTable'][ord(c)]
+     byteCls = self._mModel['classTable'][c]

コードベース全体から “ord(c)” を検索すると、同様の問題がsbcharsetprober.pyにもあることが明らかとなる……

# sbcharsetprober.py
def feed(self, aBuf):
    if not self._mModel['keepEnglishLetter']:
        aBuf = self.filter_without_english_letters(aBuf)
    aLen = len(aBuf)
    if not aLen:
        return self.get_state()
    for c in aBuf:
        order = self._mModel['charToOrderMap'][ord(c)]

……そしてlatin1prober.pyにも……

# latin1prober.py
def feed(self, aBuf):
    aBuf = self.filter_with_english_letters(aBuf)
    for c in aBuf:
        charClass = Latin1_CharToClass[ord(c)]

ここではaBufをイテレートしているので、cは1文字の文字列ではなく整数になることがわかる。解決方法は同じだ。ord(c)を単なるcに変更すればいい。

  # sbcharsetprober.py
  def feed(self, aBuf):
      if not self._mModel['keepEnglishLetter']:
          aBuf = self.filter_without_english_letters(aBuf)
      aLen = len(aBuf)
      if not aLen:
          return self.get_state()
      for c in aBuf:
-         order = self._mModel['charToOrderMap'][ord(c)]
+         order = self._mModel['charToOrderMap'][c]

  # latin1prober.py
  def feed(self, aBuf):
      aBuf = self.filter_with_english_letters(aBuf)
      for c in aBuf:
-         charClass = Latin1_CharToClass[ord(c)]
+         charClass = Latin1_CharToClass[c]

Unorderable types: int() >= str()

またテストしよう。

C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml                       ascii with confidence 1.0
tests\Big5\0804.blogspot.com.xml
Traceback (most recent call last):
  File "test.py", line 10, in <module>
    u.feed(line)
  File "C:\home\chardet\chardet\universaldetector.py", line 116, in feed
    if prober.feed(aBuf) == constants.eFoundIt:
  File "C:\home\chardet\chardet\charsetgroupprober.py", line 60, in feed
    st = prober.feed(aBuf)
  File "C:\home\chardet\chardet\sjisprober.py", line 68, in feed
    self._mContextAnalyzer.feed(self._mLastChar[2 - charLen :], charLen)
  File "C:\home\chardet\chardet\jpcntx.py", line 145, in feed
    order, charLen = self.get_order(aBuf[i:i+2])
  File "C:\home\chardet\chardet\jpcntx.py", line 176, in get_order
    if ((aStr[0] >= '\x81') and (aStr[0] <= '\x9F')) or \
TypeError: unorderable types: int() >= str()

一体全体どういうことだ? 「Unorderable types(順序付けできない型)」とは何だ? これは、バイト列と文字列の違いがまたまた姿を現したのだ。コードを見てみよう:

class SJISContextAnalysis(JapaneseContextAnalysis):
    def get_order(self, aStr):
        if not aStr: return -1, 1
        # find out current char's byte length
        if ((aStr[0] >= '\x81') and (aStr[0] <= '\x9F')) or \
           ((aStr[0] >= '\xE0') and (aStr[0] <= '\xFC')):
            charLen = 2
        else:
            charLen = 1

aStrはどこから来たのだろう? 呼び出しスタックをさかのぼろう:

def feed(self, aBuf, aLen):
    .
    .
    .
    i = self._mNeedToSkipCharNum
    while i < aLen:
        order, charLen = self.get_order(aBuf[i:i+2])

おや、これは昔なじみのaBufではないか。この章で出くわした他の問題から察しが付いていると思うが、aBufはバイト列だ。ここでfeed()メソッドは、aBufをそのまま渡しているわけではなく、これをスライスしている。しかし、この章の前の方で見たように、バイト列のスライスはバイト列を返すので、get_order()メソッドに渡されるaStrパラメータは依然としてバイト列だ。

このコードはaStrを使って何をしようとしているのだろうか? これは、バイト列の最初の要素を受け取って、それを長さ1の文字列と比較しているのだ。Python 2ではこれは動作する。なぜならaStraBufはどちらも文字列なのでaStr[0]も文字列となり、文字列同士は不等式で比較することができるからだ。しかしPython 3では、aStraBufはバイト列であり、aStr[0]は整数なので、どちらかを明示的に型強制しない限り、整数と文字列を不等式で比較することはできない。

この場合には、明示的な型強制を加えてコードをより複雑にする必要はない。aStr[0]は整数であり、ここで比較の対象になっているのはすべて定数だ。この比較のための定数を1文字の文字列から整数に変更することにしよう。そのついでに、aStraBufに変更しよう。これは実際には文字列ではないからね。

  class SJISContextAnalysis(JapaneseContextAnalysis):
-     def get_order(self, aStr):
-      if not aStr: return -1, 1
+     def get_order(self, aBuf):
+      if not aBuf: return -1, 1
          # find out current char's byte length
-         if ((aStr[0] >= '\x81') and (aStr[0] <= '\x9F')) or \
-            ((aBuf[0] >= '\xE0') and (aBuf[0] <= '\xFC')):
+         if ((aBuf[0] >= 0x81) and (aBuf[0] <= 0x9F)) or \
+            ((aBuf[0] >= 0xE0) and (aBuf[0] <= 0xFC)):
              charLen = 2
          else:
              charLen = 1

          # return its order if it is hiragana
-      if len(aStr) > 1:
-             if (aStr[0] == '\202') and \
-                (aStr[1] >= '\x9F') and \
-                (aStr[1] <= '\xF1'):
-                return ord(aStr[1]) - 0x9F, charLen
+      if len(aBuf) > 1:
+             if (aBuf[0] == 202) and \
+                (aBuf[1] >= 0x9F) and \
+                (aBuf[1] <= 0xF1):
+                return aBuf[1] - 0x9F, charLen

          return -1, charLen

  class EUCJPContextAnalysis(JapaneseContextAnalysis):
-     def get_order(self, aStr):
-      if not aStr: return -1, 1
+     def get_order(self, aBuf):
+      if not aBuf: return -1, 1
          # find out current char's byte length
-         if (aStr[0] == '\x8E') or \
-           ((aStr[0] >= '\xA1') and (aStr[0] <= '\xFE')):
+         if (aBuf[0] == 0x8E) or \
+           ((aBuf[0] >= 0xA1) and (aBuf[0] <= 0xFE)):
              charLen = 2
-         elif aStr[0] == '\x8F':
+         elif aBuf[0] == 0x8F:
              charLen = 3
          else:
              charLen = 1

        # return its order if it is hiragana
-    if len(aStr) > 1:
-           if (aStr[0] == '\xA4') and \
-              (aStr[1] >= '\xA1') and \
-              (aStr[1] <= '\xF3'):
-                 return ord(aStr[1]) - 0xA1, charLen
+    if len(aBuf) > 1:
+           if (aBuf[0] == 0xA4) and \
+              (aBuf[1] >= 0xA1) and \
+              (aBuf[1] <= 0xF3):
+               return aBuf[1] - 0xA1, charLen

        return -1, charLen

コードベースからord()関数が使われている部分を探すと、同じ問題がchardistribution.pyにもあることが分かる(具体的にはEUCTWDistributionAnalysisEUCKRDistributionAnalysisGB2312DistributionAnalysisBig5DistributionAnalysisSJISDistributionAnalysisEUCJPDistributionAnalysisクラスの中)。ここでは、それぞれのクラスについて先ほどjpcntx.pyEUCJPContextAnalysisSJISContextAnalysisクラスに加えたものと同じ修正を施せばいい。

Global name 'reduce' is not defined

突破まであと一つ……

C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml                       ascii with confidence 1.0
tests\Big5\0804.blogspot.com.xml
Traceback (most recent call last):
  File "test.py", line 12, in <module>
    u.close()
  File "C:\home\chardet\chardet\universaldetector.py", line 141, in close
    proberConfidence = prober.get_confidence()
  File "C:\home\chardet\chardet\latin1prober.py", line 126, in get_confidence
    total = reduce(operator.add, self._mFreqCounter)
NameError: global name 'reduce' is not defined

公式のWhat’s New In Python 3.0ガイドによると、reduce()関数はグローバル名前空間からfunctoolsモジュールに移されている。さらに、ガイドはこの関数について「本当に必要なのであればfunctools.reduce()を使ってほしい。だが、99%の状況においては率直なforループの方が読みやすい」としている。この決定の詳細は、Guido van Rossumのブログ記事 The fate of reduce() in Python 3000 で読める。

def get_confidence(self):
    if self.get_state() == constants.eNotMe:
        return 0.01

    total = reduce(operator.add, self._mFreqCounter)

reduce()関数は2つの引数 — 関数とリスト(厳密に言うとイテレート可能なオブジェクトなら何でもいい) — を受け取り、その関数をリストの各々の要素に累積的に適用する。言い換えると、これはリストの要素を足し合わせた結果を返すための派手で遠回しな方法だ。

この奇怪な手法はとても一般的だったので、Pythonはsum()というグローバル関数を追加している。

  def get_confidence(self):
      if self.get_state() == constants.eNotMe:
          return 0.01

-     total = reduce(operator.add, self._mFreqCounter)
+     total = sum(self._mFreqCounter)

operatorモジュールはもう使われていないので、ファイルの先頭にあるimportは除去できる。

  from .charsetprober import CharSetProber
  from . import constants
- import operator

テストは通るかなー?

C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml                       ascii with confidence 1.0
tests\Big5\0804.blogspot.com.xml                             Big5 with confidence 0.99
tests\Big5\blog.worren.net.xml                               Big5 with confidence 0.99
tests\Big5\carbonxiv.blogspot.com.xml                        Big5 with confidence 0.99
tests\Big5\catshadow.blogspot.com.xml                        Big5 with confidence 0.99
tests\Big5\coolloud.org.tw.xml                               Big5 with confidence 0.99
tests\Big5\digitalwall.com.xml                               Big5 with confidence 0.99
tests\Big5\ebao.us.xml                                       Big5 with confidence 0.99
tests\Big5\fudesign.blogspot.com.xml                         Big5 with confidence 0.99
tests\Big5\kafkatseng.blogspot.com.xml                       Big5 with confidence 0.99
tests\Big5\ke207.blogspot.com.xml                            Big5 with confidence 0.99
tests\Big5\leavesth.blogspot.com.xml                         Big5 with confidence 0.99
tests\Big5\letterlego.blogspot.com.xml                       Big5 with confidence 0.99
tests\Big5\linyijen.blogspot.com.xml                         Big5 with confidence 0.99
tests\Big5\marilynwu.blogspot.com.xml                        Big5 with confidence 0.99
tests\Big5\myblog.pchome.com.tw.xml                          Big5 with confidence 0.99
tests\Big5\oui-design.com.xml                                Big5 with confidence 0.99
tests\Big5\sanwenji.blogspot.com.xml                         Big5 with confidence 0.99
tests\Big5\sinica.edu.tw.xml                                 Big5 with confidence 0.99
tests\Big5\sylvia1976.blogspot.com.xml                       Big5 with confidence 0.99
tests\Big5\tlkkuo.blogspot.com.xml                           Big5 with confidence 0.99
tests\Big5\tw.blog.xubg.com.xml                              Big5 with confidence 0.99
tests\Big5\unoriginalblog.com.xml                            Big5 with confidence 0.99
tests\Big5\upsaid.com.xml                                    Big5 with confidence 0.99
tests\Big5\willythecop.blogspot.com.xml                      Big5 with confidence 0.99
tests\Big5\ytc.blogspot.com.xml                              Big5 with confidence 0.99
tests\EUC-JP\aivy.co.jp.xml                                  EUC-JP with confidence 0.99
tests\EUC-JP\akaname.main.jp.xml                             EUC-JP with confidence 0.99
tests\EUC-JP\arclamp.jp.xml                                  EUC-JP with confidence 0.99
.
.
.
316 tests

やったー、確かにちゃんと動いてる! /me does a little dance

まとめ

私たちは何を学んだのだろうか?

  1. どんなコードであっても、少なからぬ量のコードをPython 2からPython 3へ移植する作業は苦しいものになる。これを避けて通る道は存在しない。この作業は骨の折れるものなのだ。
  2. 自動化された2to3ツールはある程度は役に立つが、これは簡単な部分(関数名の変更、モジュール名の変更、構文の変更)しかやってくれない。このツールは見事な技術で作られたものではあるが、結局のところ、検索と置換を行う賢いロボット以上のものではない。
  3. このライブラリを移植する上で最大の障害となったのは、文字列とバイト列の違いだった。これが最大の障害になるのは当然だと思うかもしれない。そもそも、chardetモジュールはバイトのストリームを文字に変換するためのプログラムだからだ。しかし、「バイトのストリーム」というものは思いのほか色々なところに現れるものだ。ファイルを「バイナリ」モードで読み込むって? それで手に入るのはバイトのストリームだ。ウェブページを取得する? ウェブ上のAPIを呼び出す? これはどれもバイトのストリームを返すのだ。
  4. あなたは自分のプログラムを理解する必要がある。それも完璧に。できれば、自分で書いたプログラムであることが望ましいのだが、そうでなくとも、そのプログラムの奇妙な点や汚い部分に親しみを感じるくらいにならなければならない。バグはそこら中にあるのだ。
  5. テストケースは絶対に必要だ。テストケースなしに移植を行ってはいけない。私がchardetがPython 3で動作することに自信を持っている唯一の理由は、私がすべての主要なコードパスを網羅するテストスイートを作ることから始めたからだ。もしテストを一切持っていないのであれば、Python 3への移植を始める前にいくらかのテストを書こう。テストを少し持っているのであれば、もっとたくさん書こう。たくさんのテストを持っているのであれば、本当に楽しいことが始められる。

© 2001– Mark Pilgrim
© Fukada, Fujimoto(日本語版)