Python WebAcademy Blog

PythonでXMLを読むには?xml.etree.ElementTreeの基本と、find()・findall()の使い方、if文で要素を確かめるときの落とし穴を初心者向けに解説

|

設定ファイルや古いシステムの出力、RSSフィードなどでXMLを受け取って困ったことはありませんか?Python標準のxml.etree.ElementTreeを使えば、追加のインストールなしでXMLを読み書きできます。fromstring()とparse()による読み込み、find()・findall()・findtext()での検索、名前空間の扱い、XMLファイルの書き出し、巨大なファイルを少しずつ読むiterparse()、if文での判定の落とし穴と信頼できないXMLの注意点まで、サンプルコードと表で初心者向けに解説します。

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フィードから、記事のタイトルを取り出すところを試してみてください。

ここまでお読みいただきありがとうございました。

参考情報

次のアクション

記事で学んだ内容を実際に動かしてみよう

Python WebAcademyでは、ブラウザ上でコードを書きながら基礎から実践まで体系的に学べます。

Python WebAcademyの学習画面

あわせて読む

関連記事

ブログ一覧へ

Python学習ロードマップ

まずはこの3講座から

記事で気になったテーマを、順番に手を動かしながら学べます。

ロードマップを見る