トホホな疑問(13) Python、lxml、デフォルト名前空間とXPath

私なんぞは「その筋」の専門家でもなんでもないので、XMLと向き合わなければならないことなどさほど多くはありません。時折あるそんな機会も、とりあえずPythonでlxml使っておけばOK、てな感覚でおりました。XPath便利ですし、lxmlは速いのではないですかね。でもね、たまに困ることがあります。名前空間の指定されているXML、それもプリフィックス無し、デフォルト名前空間というやつが含まれるとき。そういうときはどうしたらよいんでしょうか?

※「トホホな疑問」投稿順Indexはこちら

最初に名前空間を使用していないXMLで、lxmlのXPath使って「便利」な例を掲げておきます。サンプルに使用したXMLは以下のようなズボラなものです。

<?xml version="1.0" encoding="UTF-8" ?>
<sample>
    <rec>
        <title>t1</title>
        <body>b1</body>
    </rec>
    <rec>
        <title>t2</title>
        <body>b2</body>
    </rec>
    <rec>
        <title>t3</title>
        <body>b3</body>
    </rec>
    <comp>
        <rec>
            <title>tc1</title>
            <body>tb2</body>
        </rec>
        <rec>
            <title>tc1</title>
            <body>tb2</body>
        </rec>
    </comp>
</sample>

ポイントは、sample要素の直下にrec要素がありますが、compという要素の下にもrec要素があることです。このようなケースで処理を進めるとき、階層構造が異なるので、直下にrecがあるときのみを処理したいこともあれば、階層に関わらずrecを全て処理したいなどというときもあるでしょう。XPathはそういった要求を簡単にこなせるのでとても便利です。

まず、起動して、sample直下のrecの中のtitle要素のテキストを列挙してみます。

$ python
Python 3.8.0 (tags/v3.8.0:fa919fd, Oct 14 2019, 19:37:50) [MSC v.1916 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> from lxml import etree
>>> tree = etree.parse("sampleNoNS.xml")
>>> root = tree.getroot()
>>> title=tree.xpath("/sample/rec/title")
>>> len(title)
3
>>> for elem in title:
...     print(elem.text)
...
t1
t2
t3

ちゃんとsample直下にrecがあるときのみを取り出せていることが分かります。それに対して、階層構造にかかわらず全てのtitle要素を取り出したい場合は以下のようです。

>>> title2=tree.xpath("//title")
>>> len(title2)
5
>>> for elem in title2:
...     print(elem.text)
...
t1
t2
t3
tc1
tc1

これさえあれば、大抵の要素の抽出には困りますまい。しかし、名前空間、それもデフォルト名前空間が定義されているときには、ハマりました。上に掲げたサンプルのXMLとの違いは、たった一行、rootのsample要素のところに「デフォルト名前空間」を定義しただけです。違いはこちら、

<sample xmlns="http://jhalfmoon.com/xml/t0">

先ほどと同様に処理を試みてみると、こんな感じ。

>>> treeNS = etree.parse("sample.xml")
>>> rootNS = treeNS.getroot()
>>> titleNS = treeNS.xpath("/sample/rec/title")
>>> len(titleNS)
0
>>> for elem in titleNS:
...     print(elem.text)
...
>>>

まったくもって要素を見つけてくれないではないですか。なぜかと言えば、さきほどの1行により、各要素は

“http://jhalfmoon.com/xml/t0”

という名前空間に所属しているのに対して、xpathに与えている指定は、

名前空間なし

の要素に対しての値であるからです。実際、root要素のtagを見てみると

>>> rootNS.tag
'{http://jhalfmoon.com/xml/t0}sample'

のように、名前空間つきで登場しました。浅墓にも、このtagみたいな型式で直接名前空間を指定したらxpath効くんじゃないだろうか?と思いやってみました。

>>> test0 = treeNS.xpath(rootNS.tag)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "src\lxml\etree.pyx", line 2295, in lxml.etree._ElementTree.xpath
  File "src\lxml\xpath.pxi", line 357, in lxml.etree.XPathDocumentEvaluator.__call__
  File "src\lxml\xpath.pxi", line 225, in lxml.etree._XPathEvaluatorBase._handle_result
lxml.etree.XPathEvalError: Invalid expression

駄目ですね、エラーとなります。それでちょっと調べてみると、xpathメソッドには、namespacesという引数があり、それに名前空間プリフィックス対名前空間の辞書を与えることで名前空間の解決ができるようでした。また、名前空間の辞書を取り出すために、nsmapという変数が存在しました。実際、サンプルのケースで見てみると、

>>> rootNS.nsmap
{None: 'http://jhalfmoon.com/xml/t0'}

Noneというプリフィックスに対して名前空間が得られます。しかし、このNoneが曲者でした。

>>> test1 = treeNS.xpath("/sample/rec/title", namespaces=rootNS.nsmap)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "src\lxml\etree.pyx", line 2292, in lxml.etree._ElementTree.xpath
  File "src\lxml\xpath.pxi", line 325, in lxml.etree.XPathDocumentEvaluator.__init__
  File "src\lxml\xpath.pxi", line 259, in lxml.etree.XPathElementEvaluator.__init__
  File "src\lxml\xpath.pxi", line 131, in lxml.etree._XPathEvaluatorBase.__init__
  File "src\lxml\xpath.pxi", line 55, in lxml.etree._XPathContext.__init__
  File "src\lxml\extensions.pxi", line 81, in lxml.etree._BaseContext.__init__
TypeError: empty namespace prefix is not supported in XPath

エラーメッセージのとおりです。

empty namespace prefix is not supported in XPath

XPathは名前プリフィックスの無い場合をサポートしてくれないようです。これはlxmlというより、もともとのXPathの仕様だと思います。実際、lxmlのホームページを見てみれば、それに関する記述がありました。

How can I specify a default namespace for XPath expressions?

しかし、どうしたら良いのだろうか? そこに対しても lxmlのxpathのドキュメントを見ればサジェスチョンがありました。その部分を引用させていただきます。

The prefixes you choose here are not linked to the prefixes used inside the XML document. The document may define whatever prefixes it likes, including the empty prefix, without breaking the above code.

Note that XPath does not have a notion of a default namespace. The empty prefix is therefore undefined for XPath and cannot be used in namespace prefix mappings.

明示的に名前空間を指定するときに使う名前空間プリフィックスというものですが、思ったより「仮初」なもののようです。一たび、それを手掛かりにしてXMLをパースしたアカツキには、要素共は本物の名前空間の傘下に入ってしまう。名前空間をどのような名前空間プリフィックスで「呼ぶ」かということは勝手にやって良い、ということのようです。やってみます。

>>> mynsmap = dict()
>>> mynsmap['x'] = rootNS.nsmap[None]
>>> mynsmap
{'x': 'http://jhalfmoon.com/xml/t0'}

名前空間プリフィックスをNoneでなく、勝手に決めた’x’という具体的なお名前にしたmynsmapというのを定義してみました。これを使ってみると

>> test2 = treeNS.xpath("/x:sample/x:rec/x:title", namespaces=mynsmap)
>>> len(test2)
3

名前なしのときと同じ要素が見つかりました。ちと面倒ですが、デフォルト名前空間の場合、適当なプリフィックスを付けたパスに変換すれば、元通りxpathできると。そんな適当なことでいいんかい!

トホホな疑問(12) Python3移行、文字列数値変換にハマる へ戻る

トホホな疑問(14) Python、smbusモジュール へ進む