XML
python3
REST-API

Python XMLPullParser を使う

XML を返す REST API から特定のタグの内容だけを取得したい。

Python なら簡単なことだ。標準ライブラリの ElementTree を使えばいい。

import requests
import xml.etree.ElementTree as et

endpoint = 'http://....'

req = requests.get(endpoint)
doc = et.parse(req.text)
for elem in doc.iter('title'):
    print(elem.text)

ただ、XMLの文書構造が複雑で長大なものだったり、Streaming API 的にちょろちょろとレスポンスを返すような API にはどうか。
XML のパースの前にレスポンス全体の受信完了を待つ必要があり、わずかなデータを得るためにわざわざ文書の完全なツリーが構築され、その分メモリも占有される。

それではタイトルやリンクなど XML 文書に散在する小さな要素だけをさらいたい用途には非効率だ。

できればレスポンスの XML をストリームで逐次パースし、目的のタグを見つけ次第データを出力するようにしたい。

本来ならその目的のためには SAX API を選択するが、イベントハンドラを実装するというスタイルがおっくうで気が進まない。
Python にも SAX は標準で用意されているが、ここらへんの面倒くささは特に変わらない。

その代わりというか、ExlementTree には iterparse() というお手軽パーサが用意されていて、XML タグのイベントとしてインクリメンタルに要素情報を取得することが出来る。

iterparse() は基本的にファイルを入力に取るが、HTTP通信であってもレスポンス BODY をストリームとして差し込めるはず、という気がしているのだが・・・やり方がよく分からずうまくいかない。

API ドキュメントによると XMLPullParser とかいう魅力的な名前のパーサが別にあり、こちらの方がノンブロッキングでおトクらしい。

XMLPullParser もストリームを直接差し込める訳ではないが、こちらなら Requests とうまく組み合わせればいい感じにできる気がする。

動作確認は Python 3.6.2

xml.etree.ElementTree.XMLPullParser

XMLPullParser と stream モードの Requests を使って上記コードを書き換えるとこんなところか。

import requests
import xml.etree.ElementTree as et

endpoint = 'http://....'

resp = requests.get(endpoint, stream=True)
parser = et.XMLPullParser()
for line in resp.iter_lines(decode_unicode=True):
    parser.feed(line)
    for event, elem in parser.read_events():
        if elem.tag == 'title':
            print(elem.text)

Requests を stream モードにしてテキストを1行ずつ読み込み、それをそのまま XMLPullParser に喰わ(feed)せる。
XMLPullParserは XML のテキスト断片を逐次繋げては読み、折を見てXMLイベントを起こし内部キューに乗せる。
利用者は、自分のタイミングでイベントを引き出し(pull)、要素データを消費する。

MLPullParser がサポートするイベントには、'start'、'end'、'start-ns'、'end-ns' の4つしかない、

XMLPullParser のコンストラクタには、拾いたいイベント名を複数個指定することができて、上記コードのようにそれを省略した場合、デフォルトで end イベントのみが指定される。

4つのイベントのうち 'start-ns' と 'end-ns' の2つは当面関心がない。

'start' を指定すると、XMLパース中に開始タグを読み終わった時点のイベントが返される。
要素データには開始タグの属性もキー/値で保持されている。
XML 属性だけをさらいたいときにはこれを使うのが効率的だ。

href属性のみ収集する
resp = requests.get(endpoint,  stream=True)
parser = et.XMLPullParser(['start'])
for line in resp.iter_lines(decode_unicode=True):
    parser.feed(line)
    for event, elem in parser.read_events():
        if elem.get('href') is not None:
            print(elem.get('href'))

逆に終了タグを検出した時点のイベントを拾うには 'end' を指定する。

end イベントで渡されるデータは、そのタグ範囲全体をパースした(ElementTree の)Element オブジェクトそのものだ。
つまり、その要素のテキストや子要素へのアクセスが可能な、完全なフラグメントオブジェクトが苦労せずに手に入る。

さすがは Python、気が利いている。
Java などにも XMLPullParser 実装はあるが、そこまでのサービスはしてくれない。

しかし喜んでばかりもいられない。

例えばルート要素の end イベントを拾ったらその要素オブジェクトの内容はどうなっているだろうか。
ご推察通り、文書全体の完全なツリーだ。

つまり XMLPullParser は、XML をパースしながら Eelement のツリー全体を構築するプロセスのついでにイベントを起こすだけで、読み終わったデータも含めてそのツリー全体がパーサ内部に保持されている。

ダメじゃん。

メモリを解放するために、読み終わった不要な要素をツリーから適宜削除しておきたい。
Element の clear() を呼べば自要素自身の内容を空にすることができる。
しかし親や兄弟要素については消しようもなく(そもそもアクセスでない)、気休めにしかならない。

ググってみると、iterparse() の方で同様の問題の対処法が見つかる。
ルート要素をあらかじめとっておき、イベントのたびにルートから clear() する、というのが定石らしい。

いいのかそんなことして、って普通に思うが、ElementTree 本家の公式ドキュメントからしてそう書いてある。

完全に実装依存の仕様のような気もするが、XMLPullParser でも有効だった。

import requests
import xml.etree.ElementTree as et

# QiitaのRSS(Atom)からタグフィードを取得する。
# ルート要素(feed)を開始タグイベントで変数に保持しておき、
# ターゲット要素(entry)を取り終わったらルートからclear()してしまう。
# Atom には名前空間がついていて取り扱いが面倒だが、雑な対処でやり過ごしている。
def qiitaTagFeedRss(qtag):
    ns  = {'a' : 'http://www.w3.org/2005/Atom'}  
    req = requests.get('https://qiita.com/tags/' + qtag + '/feed.atom',  stream=True)
    parser = et.XMLPullParser(['start', 'end'])
    for line in req.iter_lines(decode_unicode=True):
        parser.feed(line)
        for event, elem in parser.read_events():
            if elem.tag.endswith('}entry') and event == 'end':
                yield  elem.find('a:title', ns).text, elem.find('a:url', ns).text
                root.clear()
            elif elem.tag.endswith('}feed') and event == 'start':
                root = elem

for title, url in qiitaTagFeedRss('python'):
    print(title)
    print(url)
    print()

上記例の Atom のような平たいスキーマなら問題ないかもしれないが、再帰的構造の深い底でルートから削除されたら途中の親先祖要素たちは成仏できるのか、次に生まれる要素は誰が面倒を見るのか心配は尽きない。
また、上記コードではターゲット要素のイベントでルートをクリアしているが、発生頻度が低いタグを探すときには、もっとマメにクリアする機会を入れなければ意味がないだろう。

lxml.etree.XMLPullParser

ElementTree の上位互換を謳う lxml にも、当然ながら XMLPullParser はある。
本家より高性能だそうなのでついでに試してみよう。

イベントの種類は 'comment' と 'pi' の2つが追加されて6個となる。
comment イベントは XML コメント <!-- 〜 --> で、pi イベントは処理命令 <? 〜 ?> の内容が取得できる。
まあ使うこともないだろう。

また、lxml の XMLPullParser のコンストラクタには、イベントのほか、タグ名を複数指定することができ、仕分けコードの見通しが少しよくなる。

import requests
import lxml.etree as etree

endpoint = 'http://....'

resp = requests.get(endpoint, stream=True)
parser = etree.XMLPullParser(events=['end'], tag=['title'])
for line in resp.iter_lines(decode_unicode=True):
    parser.feed(line)
    for event, elem in parser.read_events():
        print(elem.text)

指定したタグ以外のタグの要素生成をスルーするとかいうわけではなさそうだ。

さて、不要要素の削除問題は lxml でも同様だ。

lmxl は Element も拡張していて、親要素を取得できるようになっているので、親を辿って親兄弟親戚要素の削除もやろうと思えばできる。

lxml がオススメする使用済み要素の削除方法は以下のようなロジックだ。

   for event, elem in parser.read_events():
        print(elem.text)
        # 自要素のテキストと子要素を削除する
        elem.clear() 
        # 親要素に兄要素を削除してもらう
        while elem.getprevious() is not None:
            del elem.getparent()[0]

理屈では、すべての要素の end イベントで子要素と兄要素を削除すれば、ほとんどの要素は消える。
しかし文書タイトルのような子なし長男要素のタグだけをターゲットとするときには非効率だ。
一方、何かの一覧 API のように要素が繰り返される平たいスキーマでなら有効だろう。

import requests
import lxml.etree as etree

# Redmineからチケットの一覧を取得する
# http://www.redmine.org/projects/redmine/wiki/Rest_api
def redmineIssues(key):
    api = 'https://redmine.example.jp/issues.xml?key=' + key + '&limit=1000&page=1&status_id=*'
    resp = requests.get(api, stream=True)
    parser = etree.XMLPullParser(events=['end'], tags=['issue']) # チケットごとに取得
    for line in resp.iter_lines(decode_unicode=True):
        parser.feed(line)
        for event, elem in parser.read_events():
            yield dict((chld.tag, chld.text) for chld in elem) #  dict <- Element
            elem.clear()
            while elem.getprevious() is not None:
                del elem.getparent()[0]

for issue in redmineIssues('xxxxxxAPI-KEYxxxxxxx'):
    print('#%s\t%s' % (issue['id'], issue['subject']))

文書構造が分かっているなら、適切なタグを待って丁寧に削除処理を差し込むこともできるが、Python プログラマはそんなことで時間を無駄にはしない。

lxml でもルートから全削除する本家の作戦は通用するのではないか。
実際やってみると、なんかできちゃっているような気もするが、lxml ドキュメントにはツリーを壊すような操作はやめてと書いてあるので、おとなしくしておこう。

Python いいね

Python は最近ちょっと触っただけでほぼ初心者なのだが、Pyhton には便利でわかりやすいライブラリが充実している一方でちょっと込み入った事をやろうとすると、あまり情報が出てこない印象だ。

XMLPullParser は比較的新しい?(Python 3.4 <)せいか、使いでの割にネット上に情報が乏しかったので、調べたついでに記事にした。

本当はもっと色々突っ込みたかったのだが、キリがないのでそれらはまた機会があれば。

  • パフォーマンスやフットプリントなど性能評価
  • 非同期
  • lxml には HTMLPullParser とかいうのもある。なんだろうこれ、気になるなー(棒)

参考資料