Date: 2011-04-23
Tags: python

pyreadline を2to3でPython2/3両対応にするメモ

(第7回)Python mini Hack-a-thon 午後の部の成果です。

Python 2からPython 3への移行 - YAMAGUCHI::weblog を読みながら、python readline のWindows用パッケージ pyreadline を2to3を使ってPyhton2,3両対応にしてみました。

Python3対応メモ

2to3 変換は python32 c:\Develop\Python32\Tools\Scripts\2to3.py -w --no-diffs target_dir という感じで実行。バックアップファイルが要らない場合は -wn みたいにオプションに n を付ける。

あとはひたすら Python2 でのテスト実行と 2to3.py での変換、Python3 でのテスト実行を繰り返す感じ。

以下、はまったところをメモ。

ctypes でwin32関数を取得出来なくなった

pyreadlineはWindowsで動作するために、Python本体が持っているHook関数のポインタを取得してゴニョゴニョしている。そのために以下のようにして関数ポインタを取得していた(Python2):

import sys
from ctypes import windll
handler = windll.kernel32.GetProcAddress(
                sys.dllhandle,
                "PyOS_ReadlineFunctionPointer")

でもこれだとPython3では動作しなくて、PyOS_ReadlineFunctionPointer と言う名前が変わったのか?とか思ってPython2と3のソースコード(C言語)を読んだり、ctypesの仕様が変わった?とか色々やってみて、最終的には以下のようにしたら動作した(Python3):

import sys
from ctypes import windll
handler = windll.kernel32.GetProcAddress(
                sys.dllhandle,
                b"PyOS_ReadlineFunctionPointer")

文字列の頭に b 付けただけ。あ゛ー、自動変換とか便利なものはPython3には無いんだっけなー‥

Python3でbytesになって欲しい文字列をPython2.5未満で表現出来ない

Python3では b'spam' となって欲しい文字列があったとして、Python2.6であれば素直に b'spam' と書いておけば 2to3.py で変換した後も b'spam' が維持される。

変換前(Python2.6):

spam = b'spam'
ham = u'ham'
egg = 'egg'

2to3変換後(Python3.2):

spam = b'spam'
ham = 'ham'
egg = 'egg'

しかし、Python2.6未満もサポートしているパッケージの場合、Python2.6未満では b'spam' とは書けないのでこの方法が使えない。どうするか?

変換前(Python2.4):

spam = 'spam'.encode('latin-1')
ham = u'ham'
egg = 'egg'

2to3変換後(Python3.2):

spam = 'spam'.encode('latin-1')
ham = 'ham'
egg = 'egg'

なんだかなー...

もう少しマシな方法としては @mopemope さんにアドバイス (1), (2) をもらった six の実装をまねて以下のように書くくらいか。

以下のコードをどこかに実装しておいて...

import sys
PY3 = (sys.version_info >= (3, 0))

if PY3:
    b = lambda s: s.encode('latin-1')
    u = lambda s: s
else:
    b = lambda s: s
    u = lambda s: unicode(s, "unicode_escape")

変換前(Python2.4):

spam = b('spam')
ham = u'ham'
egg = 'egg'

2to3変換後(Python3.2):

spam = b('spam')
ham = 'ham'
egg = 'egg'

文字列から1文字ずつ取り出す処理をbytesに行うと"文字は取り出されない

pyreadline はPythonのInteractiveShell上でカーソル移動や編集を行う関係上、外界と内界の境界上で str / unicode 変換 (Python3なら bytes / str 変換)を行う必要があるし、カーソル位置やなんかを保持したりいじったりする。

そんな処理の一部にこんなコードがあった(Python2):

for c in text:
    self.line_buffer[self.point] = c
    self.point += 1
...
line = ''.join.(self.line_buffer)

これはPython2時代なら文字列を1文字ずつ取り出して配列に突っ込んでいく処理なので、コードの文脈を無視して書き換えると以下のような処理をやっている(Python2):

>>> text = b('spam')
>>> buffer = [c for c in text]
>>> buffer
['s', 'p', 'a', 'm']
>>> line = ''.join.(buffer)
>>> line
'spam'

これを Python3 に置き換えて実行すると...

>>> text = b'spam'
>>> buffer = [c for c in text]
>>> buffer
[115, 112, 97, 109]
>>> line = b''.join.(buffer)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: sequence item 0: expected bytes, int found

buffer はintの配列なので b'' でjoinすることは出来ません、という事になってしまった。じゃあbytesに対して1文字ずつ処理するにはどうすれば良いのか‥ Python3.2のリファレンスを読んでも分からなかったので @atsuoishimoto 先生に助けを求めてみたところ、bytesはintの配列だから動作としては正しい、という趣旨のコメントを頂いた。うーん、、、 残念ながら標準的な解決法は今のところ無さそう。

intの配列から 文字列を 取り出そうという考え方が良くないのかもしれないけど、2to3.pyでやろうとしている以上なんとかしないといけないので、以下のような互換レイヤーを挟んで解決を図ってみた。

def biter(text):
    if PY3 and isinstance(text, bytes):
        return (s.to_bytes(1,'big') for s in text)
    else:
        return iter(text)

s.to_bytes が気持ち悪いけどまあ仕方が無いということで。これでこんな感じに動くようになった。

Python2 で実行:

>>> text = b('spam')
>>> [c for c in biter(text)]
['s', 'p', 'a', 'm']

Python3 で実行:

>>> text = b'spam'
>>> [c for c in biter(text)]
[b's', b'p', b'a', b'm']

とりあえず今日のまとめ

感想

  • pyreadlineはsyntaxやモジュールの両対応は比較的簡単だった

  • pyreadlineはコンソール操作を扱うので str / unicode / bytes 変換が多くて地獄

成果

使い方