nikkie-ftnextの日記

イベントレポートや読書メモを発信

Pythonライブラリxmltodict、XMLを辞書に変換してくれるなんて最高じゃん!🤗 触ってみたに使いこなしtipsを添えて

はじめに

胸の中にあるもの いつか見えなくなるもの♪、nikkieです。

XMLをパースするライブラリの1つ、xmltodictを知りました。
その名の通り、XMLを辞書(dict)に変換してくれるライブラリで、存在を知った時からもう胸の高鳴りが押さえきれません1
触ってみた所感とさらなる使いこなしのための素振りのまとめをバックアップとして記します。

目次

xmltodictに至るまで

最近XMLをパースする機会があり、標準ライブラリのxml.etree.ElementTreeで実装しました2
取り出したいタグをXPathで指定して解析しています。
手を動かしながら「XML自体を辞書としてパースすることもできるんじゃないかな」という電波も受信しました。

少し時が流れた後、偶然xmltodictというライブラリの存在を知りました!
Pythonヒッチハイカーズガイドの日本語版3の目次をザーッと眺めていたところ、運命的な出会いを果たすことになったのです。

xmltodict は、XMLJSONのように扱えるようにするためのもう一つの単純なライブラリです。

お試し 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
    • ネストしていきます(andplus
  • タグの属性は@属性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、どんな実装なんだろう?」と少し覗いたところ、defusedexpatxml.parsers.expatといった初めて見るライブラリをimportしていました。
そこからXML脆弱性を知ることになりました。

XML 処理モジュールは悪意を持って生成されたデータに対して安全ではありません。

defusedxml潜在的に悪意のある操作を防ぐ、修正された stdlib XML parsers のサブクラスが付属している純 Python パッケージです。信頼出来ない XML データをパースするサーバーコードではこのパッケージの使用が推奨されます。

Python公式ドキュメントによるとdefusedxmlが推奨のようですが8、xmltodictは脆弱性が残っているんでしょうか?(ここがはっきりしないので宿題です)
今回は素振りレベルでしたが、第三者XMLを入力するようなシステムで利用を考えるときは、この宿題にまず結論を出そうと思います。


  1. これはきっとあれです。ってやつです(冒頭の挨拶も「恋」から引用です)
  2. Python実践レシピ』でも取り上げられているライブラリです(15.1)。またlxmlというより高機能なライブラリもあります(15.2)。
  3. こちらのツイートがきっかけです(感謝)
  4. 実装を見たところ、parseメソッドにattr_prefix引数を渡すと@を変えられそうです(ハンドラのイニシャライザのデフォルト値が"@"
  5. Trueを指定すると全ての要素がリストに入りました。テストケースにはTrue/Falseを返すCallableを渡してTrueのタグだけリストに統一するという使い方もありました。https://github.com/martinblech/xmltodict/blob/v0.13.0/tests/test_xmltodict.py#L322-L358
  6. https://github.com/martinblech/xmltodict/blob/v0.13.0/xmltodict.py#L43-L58
  7. https://github.com/martinblech/xmltodict/blob/v0.13.0/xmltodict.py#L336-L337
  8. この点も『Python実践レシピ』読者に伝えたい!