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' を指定した場合、開始タグを読み終わった時点で start イベントが起こされる。
取得した要素データには開始タグの属性もキー/値で保持されている。
XML 属性だけをさらいたいときには'start' を使うのが効率的だ。

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 のような平たいスキーマなら問題ないかもしれないが、深い再帰的階層構造の底でルートから clear() されたら、途中の親先祖要素たちは成仏できるのか、次に生まれる要素は誰が面倒を見るのか、心配は尽きない。
また、上記コードではターゲット要素のイベントでルートをクリアしているが、発生頻度が低いタグを探している場合には、あまり意味がない。
もっとマメにクリアする機会を得るには、そのためだけに他のタグのイベントも拾わなければならない。

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 イベントで子要素と兄要素を削除すれば、ほとんどの要素は消えるはず。
ただ XML 文書構造によって、たとえば文書のタイトル要素のような「子なし長男要素」のタグだけをターゲットとするときには、非効率になるかもしれない。
一方、何かの一覧 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']))

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

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

Python いいね

個人的に Python は最近ちょっと触り始めたほぼ初心者なのだが、感想として、便利で使いやすいライブラリが充実している一方で、少し込み入った事に踏み込むと、いくら検索してもトンと情報が出てこない、という印象だ。

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

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

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

参考資料