はじめに
胸の中にあるもの いつか見えなくなるもの♪、nikkieです。
XMLをパースするライブラリの1つ、xmltodictを知りました。
その名の通り、XMLを辞書(dict)に変換してくれるライブラリで、存在を知った時からもう胸の高鳴りが押さえきれません1!
触ってみた所感とさらなる使いこなしのための素振りのまとめをバックアップとして記します。
目次
xmltodictに至るまで
最近XMLをパースする機会があり、標準ライブラリのxml.etree.ElementTreeで実装しました2。
取り出したいタグをXPathで指定して解析しています。
手を動かしながら「XML自体を辞書としてパースすることもできるんじゃないかな」という電波も受信しました。
少し時が流れた後、偶然xmltodictというライブラリの存在を知りました!
Pythonヒッチハイカーズガイドの日本語版3の目次をザーッと眺めていたところ、運命的な出会いを果たすことになったのです。
お試し xmltodict
PyPIからpip
でインストールできます:pip install xmltodict
動作環境は以下の通りです(2023/02/24追記)
- Python 3.10.9
- xmltodict 0.13.0
PyPIのページに記載されている例を動かしてみましょう。
from pprint import pprint import xmltodict XML_DATA = """ <mydocument has="an attribute"> <and> <many>elements</many> <many>more elements</many> </and> <plus a="complex"> element as well </plus> </mydocument> """ parsed = xmltodict.parse(XML_DATA) pprint(parsed)
{'mydocument': {'@has': 'an attribute', 'and': {'many': ['elements', 'more elements']}, 'plus': {'#text': 'element as well', '@a': 'complex'}}}
- タグは自身の名前が辞書のキーになります(
mydocument
)- ネストしていきます(
and
やplus
)
- ネストしていきます(
- タグの属性は
@属性
4で辞書のキーになります(@has
や@a
)- タグに属性がない場合はタグでマークアップした文字列が値になります(
<many>
)
- タグに属性がない場合はタグでマークアップした文字列が値になります(
- 同じタグが複数あると、リストとして扱われます(
<many>
)
使ってみての感想
XMLが辞書になるのは、私には非常に扱いやすかったです!
XPathを使ってET(ElementTree)で実装していくのも発見が多くて楽しいのですが、辞書は非常に慣れ親しんだデータ構造なので頭の中に描いた実装が直ちに動くコードにできるのが最高でした。
一方、exampleから離れた実際のXMLでは、扱いがやや大変と思った点があります。
タグの属性のうち、必須ではない属性が大変
同じタグで属性があったりなかったりするというケースは注意が必要でした。
- 属性があるときは
@属性
と#text
をキーに持つ辞書がネストします<awesome attr="spam">egg</awesome>
👉{'awesome': {'@attr': 'spam', '#text': 'egg'}}
- 属性がないときは辞書がネストせず、タグ名をキー、タグで囲んだ値が辞書のバリューに来ます
<awesome>egg</awesome>
👉{'awesome': 'egg'}
つまり、同じタグでも属性があったりなかったりする場合は、辞書のネストの有無を吸収する形での実装が必要でした。
先の例で示すと、parsed["mydocument"]["plus"]["#text"]
とparsed["mydocument"]["plus"]
を吸収する必要があります
XML_DATA = """ <mydocument has="an attribute"> <and> <many>elements</many> <many>more elements</many> </and> - <plus a="complex"> + <plus> element as well </plus> </mydocument> """
{'mydocument': {'@has': 'an attribute', 'and': {'many': ['elements', 'more elements']}, - 'plus': {'#text': 'element as well', '@a': 'complex'}}} + 'plus': 'element as well'}}
1つ以上あるタグも大変
複数あることもあれば1つだけというときもあるタグの扱いも注意が必要でした。
- 複数あるタグは、リストに格納されます
- 文字列のリストや、辞書のリスト
- 複数ないとき(1つだけのとき)は、リストに格納されません
- 文字列や辞書になります(複数あるときはリストに格納されていた要素の1つ1つです)
先の例で<many>
タグを1つだけにしてみましょう。
XML_DATA = """ <mydocument has="an attribute"> <and> <many>elements</many> - <many>more elements</many> </and> <plus a="complex"> element as well </plus> </mydocument> """
parsed["mydocument"]["and"]["many"]
でアクセスできる値は、型がリスト(文字列のリスト)から文字列に変わっています。
{'mydocument': {'@has': 'an attribute', - 'and': {'many': ['elements', 'more elements']}, + 'and': {'many': 'elements'}, 'plus': {'#text': 'element as well', '@a': 'complex'}}}
リストだと思って反復処理を書いていると、文字列が来たときにうまく動かなくなり、リスト型でなければ要素1のリストに変換して対処しました。
使いこなしをさがして
これらの扱いがやや大変と思った点は「xmltodictについてまだあまり知らないからだろう」と考え、リポジトリを覗いてみました。
ドキュメントは見つけられていないのですが、テストコードを頼りにして、上記の点の対処法が分かったように思います。
タグに必須ではない属性がある場合への対応
xmltodict.parse
関数にforce_cdata=True
を渡すことで、常に#text
キーを持った辞書に変換できます。
from pprint import pprint import xmltodict XML_DATA = """ <mydocument has="an attribute"> <and> <many>elements</many> <many>more elements</many> </and> - <plus a="complex"> + <plus> element as well </plus> </mydocument> """ -parsed = xmltodict.parse(XML_DATA) +parsed = xmltodict.parse(XML_DATA, force_cdata=True) pprint(parsed)
{'mydocument': {'@has': 'an attribute', 'and': {'many': [{'#text': 'elements'}, {'#text': 'more elements'}]}, - 'plus': 'element as well'}} + 'plus': {'#text': 'element as well'}}}
force_cdata=True
を指定しなかったときと比べると、<plus>
タグにa
属性があってもなくても、タグが囲んだ値にはparsed["mydocument"]["plus"]["#text"]
でアクセスできます!
force_cdata
引数はパース結果全体に作用するので、<many>
タグも#text
キーを持つようにパースされています。
force_cdata
引数の使い方は、こちらのテストケースから:
https://github.com/martinblech/xmltodict/blob/v0.13.0/tests/test_xmltodict.py#L38-L40
複数あるときも単数のときもあるタグへの対応
xmltodict.parse
関数のforce_list
引数に常にリストにしたいタグのシーケンスを渡すことができます。
from pprint import pprint import xmltodict XML_DATA = """ <mydocument has="an attribute"> <and> <many>elements</many> - <many>more elements</many> </and> <plus a="complex"> element as well </plus> </mydocument> """ -parsed = xmltodict.parse(XML_DATA) +parsed = xmltodict.parse(XML_DATA, force_list=("many",)) pprint(parsed)
{'mydocument': {'@has': 'an attribute', - 'and': {'many': 'elements'}, + 'and': {'many': ['elements']}, 'plus': {'#text': 'element as well', '@a': 'complex'}}}
<many>
タグは1つですが、値がリストに入っています!
これで<many>
タグの個数を気にしなくても、リストの処理に統一して実装できますね。
force_list
引数の使い方は、こちらのテストケースから5:
https://github.com/martinblech/xmltodict/blob/v0.13.0/tests/test_xmltodict.py#L301-L320
ちょっと深掘り:force_cdata
引数やforce_list
引数
これらは正確にはparse
関数の引数ではなく、_DictSAXHandler
のイニシャライザの引数です6。
parse
関数では可変長キーワード引数を使って実装しています7。
「parse
関数の**kwargs
ってなんで必要なんだろう?」と最初は思いましたが、parse
関数のユーザがparse
関数中で初期化するハンドラの動きを変えるのを可能にするために必要だ!と腑に落ちました。
最終形(大変と感じた点に対処できたはず)
終わりに
XMLを辞書にパースするライブラリxmltodict
の所感と、うまく使えていなかった点について調べて分かった使いこなし方をまとめました。
「XMLを辞書みたく扱えたらすごくよさそうだな〜」と思っていた私にはこのライブラリは「まさに!」という存在でした(これが恋!)
GitHubリポジトリを見ると実体は1ファイルだけで、一つのことをうまくやっている作りも非常に好みです。
いろんなライブラリで依存している存在なんじゃないかと思っていて、縁の下の力持ちとも感じますね。
XMLを扱う上で初手は『Python実践レシピ』にあたったのですが、「xmltodictは『Python実践レシピ』にぴったりだな〜」と感じました(初手で知られていたら!)。
XMLを辞書(Python使いが扱い慣れたデータ構造)に落とし込めるのが非常に助かります。
改訂するときに追加していただけませんか〜?(xmltodictに限らず、おすすめライブラリを募ったらコミュニティの力で最強の実践レシピになる気がします)
P.S. XMLの脆弱性が宿題
「xmltodict、どんな実装なんだろう?」と少し覗いたところ、defusedexpat
やxml.parsers.expat
といった初めて見るライブラリをimportしていました。
そこからXMLの脆弱性を知ることになりました。
XML 処理モジュールは悪意を持って生成されたデータに対して安全ではありません。
defusedxml は潜在的に悪意のある操作を防ぐ、修正された stdlib XML parsers のサブクラスが付属している純 Python パッケージです。信頼出来ない XML データをパースするサーバーコードではこのパッケージの使用が推奨されます。
Python公式ドキュメントによるとdefusedxmlが推奨のようですが8、xmltodictは脆弱性が残っているんでしょうか?(ここがはっきりしないので宿題です)
今回は素振りレベルでしたが、第三者がXMLを入力するようなシステムで利用を考えるときは、この宿題にまず結論を出そうと思います。
- これはきっとあれです。恋ってやつです(冒頭の挨拶も「恋」から引用です)↩
- 『Python実践レシピ』でも取り上げられているライブラリです(15.1)。またlxmlというより高機能なライブラリもあります(15.2)。↩
-
こちらのツイートがきっかけです(感謝)
↩"The Hitchhiker’s Guide to Python"は、書籍の元になったサイトで無料で読めるhttps://t.co/ioR32VvXiB
— QDくん⚡️Python x 機械学習 x 金融工学 (@developer_quant) 2023年2月13日
まさかの日本語版も無料で読めるhttps://t.co/krIDFylmnx pic.twitter.com/50ckBCzKXv -
実装を見たところ、parseメソッドに
attr_prefix
引数を渡すと@
を変えられそうです(ハンドラのイニシャライザのデフォルト値が"@"
)↩ -
True
を指定すると全ての要素がリストに入りました。テストケースにはTrue
/False
を返すCallableを渡してTrue
のタグだけリストに統一するという使い方もありました。https://github.com/martinblech/xmltodict/blob/v0.13.0/tests/test_xmltodict.py#L322-L358↩ - https://github.com/martinblech/xmltodict/blob/v0.13.0/xmltodict.py#L43-L58↩
- https://github.com/martinblech/xmltodict/blob/v0.13.0/xmltodict.py#L336-L337↩
- この点も『Python実践レシピ』読者に伝えたい!↩