JSONには慣れてきたのに、ある日いきなりXMLのファイルを渡されて戸惑ったことはありませんか?山カッコだらけの見た目に、手が止まってしまいますよね。
最近のWeb APIはJSONが主流ですが、業務システムの出力やRSSフィードなど、XMLは今も意外なところで現役です。
Pythonには、XMLを読み書きするためのxml.etree.ElementTreeが標準で入っています。追加のインストールは要らないので、思い立ったその場で試せます。
この記事では、基本の読み書きからつまずきやすい落とし穴までを見ていきます。実行結果は手元のPython 3.11で確認したものです。
XMLとElementTreeの関係をざっくりつかむ¶
まずはXMLそのものの形をおさらいしておきましょう。XMLは、タグで囲んだ部品を入れ子にしてデータを表す書き方です。
部品のひとつひとつを要素と呼び、要素は属性やテキスト、子どもの要素を持てます。親子関係が積み重なった木構造と考えるとわかりやすくなります。
ElementTreeは、この木構造をPythonのオブジェクトとして扱えるようにしたモジュールです。
要素を表すElementオブジェクトには、よく使う情報がいくつか入っています。次の表に、まず覚えておきたい4つをまとめました。
| 属性 | 中身 | 例 |
|---|---|---|
| tag | 要素の名前 | book |
| attrib | 属性を集めた辞書 | {'id': 'b1', 'lang': 'ja'} |
| text | 開始タグの直後にある文字列 | はじめてのPython |
| tail | 終了タグの直後にある文字列 | 改行や空白であることが多い |
attribは普通の辞書なので、辞書の操作に慣れていればそのまま使えます。
【関連記事】Pythonの辞書(dict)とは?キーと値でデータを管理する基本を初心者向けに解説
XMLを読み込む2つの方法¶
ElementTreeでXMLを読む入り口は、大きく分けて2つあります。文字列から読むか、ファイルから読むかの違いです。
文字列から読むfromstring()¶
APIのレスポンスなど、XMLがすでに文字列として手元にあるときはfromstring()を使います。戻り値は、いちばん外側の要素であるルート要素です。
次のコードでは、本の一覧を表すXMLを読み込んで、それぞれのタイトルと価格を表示しています。3冊目だけ価格が書かれていない点に注目してください。
import xml.etree.ElementTree as ET
xml_text = """<?xml version="1.0" encoding="UTF-8"?>
<library>
<book id="b1" lang="ja">
<title>はじめてのPython</title>
<price>2400</price>
</book>
<book id="b2" lang="en">
<title>Fluent Python</title>
<price>5800</price>
</book>
<book id="b3" lang="ja">
<title>データ分析入門</title>
</book>
</library>
"""
root = ET.fromstring(xml_text)
print(root.tag) # library
for book in root.findall("book"):
title = book.findtext("title")
price = book.findtext("price", default="未定")
print(book.get("id"), book.attrib["lang"], title, price)
# b1 ja はじめてのPython 2400
# b2 en Fluent Python 5800
# b3 ja データ分析入門 未定
ETという別名は、公式ドキュメントでも使われている定番の書き方です。属性はget()で読むと、存在しないときにNoneが返るので安心です。
ファイルから読むparse()¶
XMLがファイルに保存されているなら、parse()にファイル名を渡します。こちらはElementTreeオブジェクトが返ってくるので、getroot()でルート要素を取り出してから使います。
コードにすると、tree = ET.parse("books.xml") のあとに root = tree.getroot() と続けるだけです。
with文で開いたファイルオブジェクトをparse()に渡すこともできます。
【関連記事】Pythonのファイル読み書きとは?open()とwith文の基本を初心者向けに解説
find()・findall()・findtext()で要素を探す¶
読み込んだあとは、目的の要素を探す作業が中心になります。よく使う検索メソッドは3つで、それぞれ返ってくるものが違います。
違いを表にまとめると次のとおりです。迷ったときは、戻り値の欄を見て選ぶと失敗しにくくなります。
| メソッド | 戻り値 | 見つからないとき |
|---|---|---|
| find() | 最初に見つかった要素1つ | None |
| findall() | 見つかった要素すべてのリスト | 空のリスト |
| findtext() | 最初に見つかった要素のテキスト | Noneか、defaultで渡した値 |
| iter() | 子孫をすべてたどるイテレータ | 何も返さずに終わる |
findtext()は、要素を探してtextを取り出すところまでを1回でやってくれます。さきほどのサンプルのように、default引数を使えば価格が無い本も止まらずに処理できます。
検索の文字列には、XPathという書き方の一部が使えます。ただし公式ドキュメントによると、サポートしているのは省略形の小さなサブセットで、完全なXPathエンジンではありません。
それでも、.//titleで下の階層すべてを探したり、book[@lang='ja']で属性を条件にしたりと、よく使う書き方はそろっています。さきほどのXMLでroot.findall("book[@lang='ja']")を試すと、b1とb3の2冊が返ってきました。
if文で要素を確かめるときの落とし穴¶
find()の結果をif文でそのまま判定すると、思わぬ動きをすることがあります。
Element オブジェクトは、子どもの要素を持っていないと真偽値がFalseになります。つまり、要素がちゃんと見つかっていても、中が文字だけならif文の条件はFalseになってしまうのです。
私は以前、取引先から届くXMLを取り込むバッチで、この挙動に気づかずにバグを出したことがあります。値が入っているはずの項目が、なぜか未入力として扱われていて、原因にたどり着くまで半日かかりました。
さきほどのXMLでtitle要素を探して、判定の書き方による違いを確かめてみましょう。
title = root.find("book/title")
print(len(title)) # 0(子どもの要素が無い)
print(title is not None) # True(要素は見つかっている)
# よくない書き方: 見つかっていてもFalseになる
if title:
print("このメッセージは表示されない")
# よい書き方: 見つかったかどうかを明示する
if title is not None:
print(title.text) # はじめてのPython
公式ドキュメントにも、この点への注意がはっきり書かれています。Python 3.12からは要素の真偽値を判定するとDeprecationWarningが出るようになり、将来は子どもの有無に関係なく常にTrueになる予定です。
見つかったかどうかを知りたいならis not None、子どもがいるかを知りたいならlen()と、目的に合わせて明示的に書くのが安全です。
【関連記事】Noneはただの空ではない。Pythonに1つしか存在しないシングルトンの正体とは?
名前空間つきのXMLを扱う¶
RSSやAtomのフィードを読もうとして、findall()が空のリストしか返さずに首をかしげた経験はありませんか?その原因の多くは、名前空間です。
名前空間とは、タグ名の衝突を防ぐために付けるURLのような識別子で、xmlns="..." という属性で指定されます。
ElementTreeは名前空間をタグ名の前に波カッコで付けて管理するので、見た目どおりにfindall("entry")と書いても一致しません。
実際の動きを見てみましょう。namespaces引数に接頭辞とURLの対応を辞書で渡すと、短い名前で検索できるようになります。
import xml.etree.ElementTree as ET
atom = """<feed xmlns="http://www.w3.org/2005/Atom">
<entry><title>A</title></entry>
<entry><title>B</title></entry>
</feed>"""
root = ET.fromstring(atom)
print(root.tag) # {http://www.w3.org/2005/Atom}feed
print(root.findall("entry")) # [] 何も見つからない
ns = {"atom": "http://www.w3.org/2005/Atom"}
titles = [e.findtext("atom:title", namespaces=ns)
for e in root.findall("atom:entry", ns)]
print(titles) # ['A', 'B']
# Python 3.8以降は {*} でどの名前空間にも一致させられる
print([e.text for e in root.findall(".//{*}title")]) # ['A', 'B']
{*} のワイルドカードは手軽ですが、別の名前空間の同名タグも拾ってしまいます。仕様が決まっているXMLなら、namespacesで明示するほうが意図が伝わります。
XMLを作ってファイルに書き出す¶
ElementTreeは読むだけでなく、XMLを組み立てて保存することもできます。
要素を作るにはElement()、子どもを付け足すにはSubElement()を使います。テキストは、作った要素のtextに代入するだけです。
次のコードは、ユーザーの一覧をXMLファイルとして書き出す例です。indent()はPython 3.9で追加された関数で、人が読みやすいように字下げを入れてくれます。
import xml.etree.ElementTree as ET
root = ET.Element("users")
for i, name in enumerate(["佐藤", "鈴木"], start=1):
user = ET.SubElement(root, "user", id=str(i))
ET.SubElement(user, "name").text = name
tree = ET.ElementTree(root)
ET.indent(tree)
tree.write("users.xml", encoding="utf-8", xml_declaration=True)
encodingを指定し忘れると日本語が文字参照に置き換わってしまうので、日本語を含むならutf-8を明示しておきましょう。
巨大なXMLはiterparse()で少しずつ読む¶
数百MBのログのように、とても大きなXMLを扱う場面もあります。parse()は全体を一度にメモリへ読み込むため、そのままではメモリが足りなくなることがあります。
そんなときはiterparse()を使い、要素を読み終えるたびに処理して、終わった要素をclear()で空にします。
公式ドキュメントには、iterparse()は木を少しずつ組み立てるものの、少しずつ解放するわけではないと書かれています。clear()を呼ばないと、結局すべての要素がメモリに残るので注意してください。
以下は、ログのXMLからERRORの件数を数える例です。
import xml.etree.ElementTree as ET
error_count = 0
for event, elem in ET.iterparse("logs.xml", events=("end",)):
if elem.tag == "log":
if elem.get("level") == "ERROR":
error_count += 1
elem.clear()
print(error_count)
eventsにendを指定しているのは、終了タグまで読み終えた時点で受け取るためです。startの時点では、テキストや子どもの要素がまだそろっていない可能性があります。
信頼できないXMLを読むときの注意¶
最後に、セキュリティの話をしておきます。公式ドキュメントのXMLモジュールのページには、信頼できないデータを解析するならXMLのセキュリティの節を読むようにという注意書きがあります。
XMLには実体と呼ばれる置き換え機能があり、これを悪用して大量の文字列を展開させる攻撃が知られています。
公式ドキュメントで紹介されている攻撃を、次の表に整理しました。
| 攻撃の名前 | 中身 |
|---|---|
| billion laughs | 入れ子にした実体を指数関数的に展開させる |
| quadratic blowup | 大きな実体を何度もくり返して展開させる |
| large tokens | 未完了のトークンの再解析で処理時間を2乗に増やす |
| decompression bomb | 圧縮されたXMLを展開させて巨大にする |
PythonのXMLパーサーは、内部でExpatというライブラリを使っています。最新の公式ドキュメントでは、Expatが2.7.2より古いと上の一部の攻撃に弱い可能性があるとされ、pyexpat.EXPAT_VERSIONで確認するよう案内されています。
ユーザーがアップロードしたXMLを受け付けるような仕組みなら、Pythonと関連ライブラリを新しく保つことに加えて、標準のパーサーに安全策を加えたdefusedxmlのようなライブラリの利用も検討しましょう。
JSONやBeautifulSoupとの使い分け¶
自分でデータの形式を決められるなら、Pythonでは扱いやすいJSONを選ぶのが一般的です。
相手のシステムがXMLしか出してくれないなら、ElementTreeの出番です。
【関連記事】PythonでJSONデータの扱いをマスター!API連携に必須のjsonモジュールの使い方
また、HTMLから情報を取り出したいときは、ElementTreeよりBeautifulSoupが向いています。Webページは閉じタグの抜けなど書き方が厳密でないことが多く、XMLのパーサーではエラーになりやすいからです。
見た目が似ていても、求められる厳密さが違います。データの形式と出どころに合わせて道具を選ぶことが、いちばんの近道です。
【関連記事】PythonのBeautifulSoupとは?Webページから情報を取り出すスクレイピングの基本を初心者向けに解説
まとめ¶
xml.etree.ElementTreeを使えば、標準ライブラリだけでXMLを読み、探し、書き出せます。fromstring()とparse()で読み込み、find()・findall()・findtext()で目的の要素を探すのが基本の流れです。
if文で要素を判定するときは、is not Noneとlen()を使い分けてください。名前空間つきのXMLでは、namespaces引数か{*}のワイルドカードを思い出しましょう。
大きなファイルはiterparse()とclear()で少しずつ処理し、外部から受け取るXMLではセキュリティの注意も忘れずに確認してください。
まずは身近なRSSフィードから、記事のタイトルを取り出すところを試してみてください。
ここまでお読みいただきありがとうございました。