Python での xml 処理で lxml すら助けにならないとき

今まで気付かなかったのが不思議だ…。

たんまり 2、3 時間は悩んだ。かなり「凶悪」で「repr で逃げられない」パターンにはまり、「どこで、何が」を特定するまで時間喰い過ぎた。

起こったことは結果的に簡単に説明出来る。ひっじょーにしょーもないことだ。以下2つの XML があると思って欲しい。

xml_cp932.xml
1 <?xml version="1.0" encoding="cp932"?>
2 <doc>
3   <file path="c:\Bogus\Tako\takos.wav"/>
4 </doc>
xml_shift_jis.xml
1 <?xml version="1.0" encoding="shift_jis"?>
2 <doc>
3   <file path="c:\Bogus\Tako\takos.wav"/>
4 </doc>

この XML 宣言を見ただけでイヤな気分になるとしたら、結構経験豊富な人だろうね。まぁそういう人々がこれに気付いてないってのも考えにくい気がするんだけれども。これはヒドい:

 1 Python 2.7.9 (default, Dec 10 2014, 12:28:03) [MSC v.1500 64 bit (AMD64)] on win32
 2 Type "help", "copyright", "credits" or "license" for more information.
 3 >>> from __future__ import unicode_literals
 4 >>> 
 5 >>> from xml.etree import ElementTree as ET
 6 >>> from lxml import etree as LXML_ET
 7 >>> 
 8 >>> def parse1(xmlfile):
 9 ...     try:
10 ...         # first try with etree in stdlib
11 ...         return ET.parse(xmlfile).getroot()
12 ...     except (ValueError, LookupError):
13 ...         # maybe "multi-byte encodings are not supported",
14 ...         # then we use lxml...
15 ...         return LXML_ET.parse(xmlfile).getroot()
16 ... 
17 >>> #
18 >>> root = parse1("xml_cp932.xml")
19 >>> files = list(root)
20 >>> 
21 >>> for f in files:
22 ...     print([v for k, v in f.attrib.items()])
23 ... 
24 ['c:\\Bogus\\Tako\\takos.wav']
25 >>> #
26 >>> root = parse1("xml_shift_jis.xml")
27 >>> files = list(root)
28 >>> 
29 >>> for f in files:
30 ...     print([v for k, v in f.attrib.items()])
31 ... 
32 [u'c:\xa5Bogus\xa5Tako\xa5takos.wav']
33 >>> for f in files:
34 ...     print([v for k, v in f.attrib.items()][0])
35 ... 
36 Traceback (most recent call last):
37   File "<console>", line 2, in <module>
38 UnicodeEncodeError: 'ascii' codec can't encode character u'\xa5' in position 2: ordinal not in range(128)
39 >>> for f in files:
40 ...     print([v.encode("mbcs").decode() for k, v in f.attrib.items()][0])
41 ... 
42 c:\Bogus\Tako\takos.wav

標準ライブラリの xml サポートは expat に拠るものであり、これは utf-8 エンコードしか扱えないため、今回のケースでは全て lxml 版が使われる。もちろんそのために lxml に fallback していた。でもこれじゃ「時限爆弾」と一緒。末端ノードまで手繰りに手繰ってから encode、decode なんて、アリエヘン。

バックスラッシュなのですごーく心当たりはあるんだけれどね。例の「円マークなのぞ、バックスラッシュなのや」のマッピングだよな、この問題おそらく。

仕方がないのでもう lxml から離れてこうしちゃった:

 1 Python 2.7.9 (default, Dec 10 2014, 12:28:03) [MSC v.1500 64 bit (AMD64)] on win32
 2 Type "help", "copyright", "credits" or "license" for more information.
 3 >>> from __future__ import unicode_literals
 4 >>> 
 5 >>> import re
 6 >>> from xml.etree import ElementTree as ET
 7 >>> 
 8 >>> _XMLDECL_RGX = re.compile(
 9 ...     br'''<\?xml\s+version=(["'])\d\.\d\1\s+encoding=(["'])([\w\d_-]+)\2\?>''')
10 >>> 
11 >>> def parse2(xmlfile):
12 ...     import io
13 ...     m = _XMLDECL_RGX.match(io.open(xmlfile, "rb").read(200))
14 ...     if not m:
15 ...         xmlstr = io.open(xmlfile, "r", encoding="utf-8").read()
16 ...     else:
17 ...         enc = m.group(3).decode()
18 ...         hdl = len(m.group(0))
19 ...         xmlstr = io.open(xmlfile, "r", encoding=enc).read()
20 ...         xmlstr = '<?xml version="1.0"?>' + xmlstr[hdl:]
21 ...     #
22 ...     return ET.fromstring(xmlstr)
23 ... 
24 >>> 
25 >>> #
26 >>> root = parse2("xml_cp932.xml")
27 >>> files = list(root)
28 >>> 
29 >>> for f in files:
30 ...     print([v for k, v in f.attrib.items()])
31 ... 
32 ['c:\\Bogus\\Tako\\takos.wav']
33 >>> #
34 >>> root = parse2("xml_shift_jis.xml")
35 >>> files = list(root)
36 >>> 
37 >>> for f in files:
38 ...     print([v for k, v in f.attrib.items()])
39 ... 
40 ['c:\\Bogus\\Tako\\takos.wav']

少なくとも標準 Python にとって既知のエンコーディングであれば読み込める。例の「Windows-31J 地獄」や「ms932 祭り」は…、XML 宣言の encoding を replace しちゃえば、と思う。「それでも対応出来ないものなら lxml」という fallback は…、ワタシには必要ないけど、気になる人はそうしとけば「ほぼ完全」でしょう。(あと BOM が付いたヘンチクリン utf-8 (いわゆる Microsoft が大好きな utf-8-sig) の考慮は上のはしてないんで、完全にしたい人はそこまでやったらいい。)

にしても ElementTree は parse と fromstring で戻りのインターフェイスが違うのがちょっとトチ狂ってる。なげに同じに出来んだか。


8:20追記:
utf-8-sig 問題についてはこれだけ:

 1 Python 2.7.9 (default, Dec 10 2014, 12:28:03) [MSC v.1500 64 bit (AMD64)] on win32
 2 Type "help", "copyright", "credits" or "license" for more information.
 3 >>> from __future__ import unicode_literals
 4 >>> 
 5 >>> import re
 6 >>> from xml.etree import ElementTree as ET
 7 >>> 
 8 >>> _XMLDECL_RGX = re.compile(
 9 ...     br'''<\?xml\s+version=(["'])\d\.\d\1\s+encoding=(["'])([\w\d_-]+)\2\?>''')
10 >>> 
11 >>> def parse2(xmlfile):
12 ...     import io
13 ...     m = _XMLDECL_RGX.match(io.open(xmlfile, "rb").read(200))
14 ...     if not m:
15 ...         xmlstr = io.open(xmlfile, "r", encoding="utf-8-sig").read()
16 ...     else:
17 ...         enc = m.group(3).decode()
18 ...         hdl = len(m.group(0))
19 ...         xmlstr = io.open(xmlfile, "r", encoding=enc).read()
20 ...         xmlstr = '<?xml version="1.0"?>' + xmlstr[hdl:]
21 ...     #
22 ...     return ET.fromstring(xmlstr.encode("utf-8"))
23 ... 
24 >>> 
25 >>> #
26 >>> root = parse2("xml_cp932.xml")
27 >>> files = list(root)
28 >>> 
29 >>> for f in files:
30 ...     print([v for k, v in f.attrib.items()])
31 ... 
32 ['c:\\Bogus\\Tako\\takos.wav']
33 >>> #
34 >>> root = parse2("xml_shift_jis.xml")
35 >>> files = list(root)
36 >>> 
37 >>> for f in files:
38 ...     print([v for k, v in f.attrib.items()])
39 ... 
40 ['c:\\Bogus\\Tako\\takos.wav']

要するに

  1. XML 宣言に encoding 指定がないなら utf-8 である、というのは XML の仕様。
  2. 正規表現が signature があるために決して match しないことを逆手に取ってしまう。
  3. つまり、「not m」が真になるのは utf-8 か utf-8-sig の場合だけ。
  4. utf-8-sig は「迷惑な signature を無視するだけ」であって、「signature がなければならぬのだのだのだ」なんてことは言わないことを利用する。

てこと。encode して外部化したい場合は -sig とそうでないのを厳密に使い分けなければならないけれど、decode 時は「よきにはからえ」で良いつーこと。

ただ、fromstring に「encode」して渡さなければならないのはちょっとね…、釈然としないと言うか。これしないと fromstring 自身が (ascii で) encode しようと試みて自爆するのであった。

無論「知識がないままバカ(というか XML 仕様を無視した)な XML を作っちゃった場合」はどーしようもないよ。そういうダメなパーサの実例を知らないが、encoding 指定と実際のエンコーディングが不整合のものを「読めてしまう」パーサを持ってるなら、今すぐ捨てた方がいい。そんなもん使ってると「迷惑な XML」を撒き散らすことになるから。(今もそうだが「XML」ではなく「HTML パーサ」はこれに関して不適切に寛容。けどもうこれは「かつてのエンジニアたち」がやらかしてしまった歴史上、やむをえない。)


18:40追記:
上の追記、説明がちょっとおかしいのが気になったのと、もう一つ議論の抜けも気になって。

まず「説明がヘン」について。fromstring に encode して渡さねばならんのやーについてだけど、これは「XML 宣言で utf-8 variant でない場合」も同じだよね。何言っちゃったんだか。

次に「議論の抜け」。言うまでもなく「utf-16 variant」の話。これは BOM 必須なので、上のような「自力」かます場合は、この判定処理が不可欠になる。無論「not m が真になるのは」の上での主張はこうなるとウソ。

けど utf-16 な XML を手にしてしまって不幸になった経験はワタシはないので、当座ほっとく。あなたが気になるなら、それこそ lxml にフォールバックするなり、あるいは自力で BOM 解読して適切な utf-16 系でデコードするなり。