本节书摘来自异步社区《Python 3程序开发指南(第2版•修订版)》一书中的第7章,第7.3节,作者[英]Mark Summerfield,王弘博,孙传庆 译,更多章节内容可以访问云栖社区“异步社区”公众号查看。
7.3 写入与分析XML文件
有些程序将其处理的所有数据都使用XML文件格式,还有些其他程序将XML用作一种便利的导入/导出格式。即便程序的主要格式是文本格式或二进制格式,导入与导出XML的能力也是有用的,并且始终是值得考虑的一项功能。
Python提供了3种写入XML文件的方法:手动写入XML;创建元素树并使用其write()方法;创建DOM并使用其write()方法。XML文件的读入与分析则有4种方法:人工读入并分析XML(不建议采用这种方法,这里也没有进行讲述——正确处理某些更晦涩和更高级的可能是非常困难的);使用元素树;DOM(文档对象模型);SAX(Simple API for XML,用于XML的简单API)分析器。
图7-5给出了航空器事故记录的XML格式。在本节中,我们就来展示如何手动写入XML格式与如何使用元素树、DOM写入,以及如何使用元素树、DOM、SAX分析器读入并分析XML文件。如果你并不关心采用哪种方法读、写XML文件,就可以在阅读完“元素树”小节,直接跳到本章的7.4节(随机存取二进制文件)。
7.3.1 元素树
使用元素树写入XML数据分为两个阶段:首先,要创建用于表示XML数据的元素树;之后,将元素树写入到文件中。有些程序可能使用元素树作为其数据结构,这种情况下,第一阶段可以省略,只需要直接写入数据。我们分两个部分来查看export_xml_etree()方法:
def export_xml_etree(self, filename):
root = xml.etree.ElementTree.Element("incidents")
for incident in self.values():
element = xml.etree.ElementTree.Element("incident",
report_id=incident.report_id,
date=incident.date.isoformat(),
aircraft_id=incident.aircraft_id,
aircraft_type=incident.aircraft_type,
pilot_percent_hours_on_type=str(
incident.pilot_percent_hours_on_type),
pilot_total_hours=str(incident.pilot_total_hours),
midair=str(int(incident.midair)))
airport = xml.etree.ElementTree.SubElement(element,
"airport")
airport.text = incident.airport.strip()
narrative = xml.etree.ElementTree.SubElement(element,
"narrative")
narrative.text = incident.narrative.strip()
root.append(element)
tree = xml.etree.ElementTree.ElementTree(root)
我们从创建根元素()开始,之后对所有事故记录进行迭代。对每条事故记录,我们创建一个元素()来存放该事故记录的数据,并使用关键字参数来提供属性。所有属性必须都是文本,因此,我们需要对日期、数值型数据、布尔型数据项进行相应转换。我们不必担心对“&”、“<”、“>”(或属性值中的引号)的转义处理,因为元素树模块(以及SOM、SAX模块)会对相关的详细资料进行自动处理。
每个包含两个子元素,一个用于存放机场名,另一个用于存放叙述性文本。创建子元素时,必须为其提供父元素与标签名。元素的读/写text属性则用于存放其文本。
及其所有属性、子元素与创建之后,我们将其添加到树体系的根()元素,反复进行这一过程,最终的元素体系中就包含了所有事故记录数据,这些数据可以转换为元素树。
try:
tree.write(filename, "UTF-8")
except EnvironmentError as err:
print("{0}: import error: {1}".format(
os.path.basename(sys.argv[0]), err))
return False
return True
写入XML数据来表示一个完整的元素树,实际上只是使用给定的编码格式将元素树本身写入到文件中。
到现在为止,在指定编码格式时,我们几乎总是使用字符串"utf8",这对Python内置的open()函数而言是可以正常工作的,该函数可以接受很多种编码方式以及这些编码方式名称的变种,比如“UTF-8”、“UTF8”、“utf-8”以及“utf8”。但对XML文件而言,编码方式名称只能是正式名称,因此,“utf8”是不能接受的,这也是为什么我们严格地使用“UTF-8”。1
使用元素树读取XML文件并不比写入难多少,也分为两个阶段:首先读入并分析XML文件,之后对生成的元素树进行遍历,以便读取数据来生成incidents字典。同样地,如果元素树本身已经是内存中存储的数据结构,第二阶段就不是必要的。下面分两部分给出import_xml_etree()方法。
def import_xml_etree(self, filename):
try:
tree = xml.etree.ElementTree.parse(filename)
except (EnvironmentError,
xml.parsers.expat.ExpatError) as err:
print("{0}: import error: {1}".format(
os.path.basename(sys.argv[0]), err))
return False
默认情况下,元素树分析器使用expat XML分析器,这也是为什么我们必须做好捕获expat异常的准备。
self.clear()
for element in tree.findall("incident"):
try:
data = {}
for attribute in ("report_id", "date", "aircraft_id",
"aircraft_type",
"pilot_percent_hours_on_type",
"pilot_total_hours", "midair"):
data[attribute] = element.get(attribute)
data["date"] = datetime.datetime.strptime(
data["date"], "%Y-%m-%d").date()
data["pilot_percent_hours_on_type"] = (
float(data["pilot_percent_hours_on_type"]))
data["pilot_total_hours"] = int(
data["pilot_total_hours"])
data["midair"] = bool(int(data["midair"]))
data["airport"] = element.find("airport").text.strip()
narrative = element.find("narrative").text
data["narrative"] = (narrative.strip()
if narrative is not None else "")
incident = Incident(**data)
self[incident.report_id] = incident
except (ValueError, LookupError, IncidentError) as err:
print("{0}: import error: {1}".format(
os.path.basename(sys.argv[0]), err))
return False
return True
准备好元素树之后,就可以使用xml.etree.ElementTree.findall()方法对每个进行迭代处理了。每个事故都是以一个xml.etree.Element对象的形式返回的。在处理元素属性时,我们使用的是与前面import_text_regex()方法中同样的技术——我们首先将所有值存储到data字典中,之后将日期、数字、布尔型值转换到正确的类型。对机场属性与叙述性文本元素,我们使用xml.etree.Element.find()方法寻找这些值,并读取其text属性。如果某个文本元素不包含文本,那么其text属性将为None,因此,在读取叙述性文本元素时,我们必须考虑这一点,因为该元素可以为空。在所有情况下,返回给我们的属性值与文本都不包含XML转义,因为其是自动非转义的。
与用于处理航空器事故数据的所有XML分析器类似,如果航空器或叙述性文本元素丢失,或某个属性丢失,或某个转换过程失败,或任意的数值型数据超出了取值范围,都会产生异常——这将确保无效数据被终止分析并输出错误消息。用于创建并存储事故记录以及处理异常的代码与前面看到的相同。
7.3.2 DOM
DOM是一种用于表示与操纵内存中XML文档的标准API。用于创建DOM并将其写入到文件的代码,以及使用DOM对XML文件进行分析的代码,在结构上与元素树代码非常相似,只是稍长一些。
我们首先分两个部分查看export_xml_dom()方法。这一方法分为两个阶段:首先创建一个DOM来表示事故记录数据,之后将该DOM写入到文件。就像使用元素树写入时一样,有些程序可能使用DOM作为其数据结构,在这种情况下可以省略第一步,直接写入数据。
def export_xml_dom(self, filename):
dom = xml.dom.minidom.getDOMImplementation()
tree = dom.createDocument(None, "incidents", None)
root = tree.documentElement
for incident in self.values():
element = tree.createElement("incident")
for attribute, value in (
("report_id", incident.report_id),
("date", incident.date.isoformat()),
("aircraft_id", incident.aircraft_id),
("aircraft_type", incident.aircraft_type),
("pilot_percent_hours_on_type",
str(incident.pilot_percent_hours_on_type)),
("pilot_total_hours",
str(incident.pilot_total_hours)),
("midair", str(int(incident.midair)))):
element.setAttribute(attribute, value)
for name, text in (("airport", incident.airport),
("narrative", incident.narrative)):
text_element = tree.createTextNode(text)
name_element = tree.createElement(name)
name_element.appendChild(text_element)
element.appendChild(name_element)
root.appendChild(element)
该方法从获取一个DOM实现开始,默认情况下,DOM实现是由expat XML分析器提供的,xml.dom.minidom模块提供了一个比xml.dom模块所提供的更简单、更短小的DOM实现,尽管该模块使用的对象来自于xml.dom模块。获取了DOM实现后,我们可以创建一个文档。xml.dom.DOMImplementation.createDocument()的第一个参数是名称空间URI——我们并不需要,因此将其赋值为None;第二个参数是一个限定名(根元素的标签名);第三个参数是文档类型,同样,也将其赋值为None,因为我们没有文档类型。在获取了表示文档的树之后,我们取回根元素,之后对所有事故记录进行迭代。
对每个事故记录,我们创建一个元素,对事故的每个属性,我们使用该属性名与值调用setAttribute()。就像元素树中一样,我们也不需要担心“&”、“<”与“>”(或属性值中的引号)的转义问题。对机场与叙述性文本元素,我们必须创建一个文本元素来存放文本,并以一个通常的元素(带有适当的标签名)作为文本元素的父亲——之后,我们将该通常元素(及其包含的文本元素)添加到当前的事故元素中。事故元素完整后,就将其添加到根。
fh = None
try:
fh = open(filename, "w", encoding="utf8")
tree.writexml(fh, encoding="UTF-8")
return True
我们没有给出except语句块以及finally语句块,因为这与我们前面已经看到的都是相同的。从上面的代码中可以清晰看到的是,内置的open()函数使用的编码字符串与用于XML文件的编码字符串之间的差别,这一点在前面也已讨论。
将XML文档导入到DOM中与导入到元素树中是类似的,但与从元素树中导出类似,导入到DOM也需要更多的代码。我们将分3个部分来查看import_xml_dom()函数,下面先给出其def行以及嵌套的get_text()函数。
def import_xml_dom(self, filename):
def get_text(node_list):
text = []
for node in node_list:
if node.nodeType == node.TEXT_NODE:
text.append(node.data)
return "".join(text).strip()
get_text()函数在一个节点列表(比如某节点的子节点)上进行迭代,对每个文本节点,提取该节点的文本并将其附加到文本列表中。最后,该函数返回已收集到一个单独的字符串中的所有文本,并且剥离掉两端的空白字符。
try:
dom = xml.dom.minidom.parse(filename)
except (EnvironmentError,
xml.parsers.expat.ExpatError) as err:
print("{0}: import error: {1}".format(
os.path.basename(sys.argv[0]), err))
return False
使用DOM分析XML文件是容易的,因为模块为我们完成了所有困难的工作,但是我们必须做好处理expat错误的准备,因为就像元素树一样,expat XML分析器也是DOM类使用的默认分析器。
self.clear()
for element in dom.getElementsByTagName("incident"):
try:
data = {}
for attribute in ("report_id", "date", "aircraft_id",
"aircraft_type",
"pilot_percent_hours_on_type",
"pilot_total_hours", "midair"):
data[attribute] = element.getAttribute(attribute)
data["date"] = datetime.datetime.strptime(
data["date"], "%Y-%m-%d").date()
data["pilot_percent_hours_on_type"] = (
float(data["pilot_percent_hours_on_type"]))
data["pilot_total_hours"] = int(
data["pilot_total_hours"])
data["midair"] = bool(int(data["midair"]))
airport = element.getElementsByTagName("airport")[0]
data["airport"] = get_text(airport.childNodes)
narrative = element.getElementsByTagName(
"narrative")[0]
data["narrative"] = get_text(narrative.childNodes)
incident = Incident(**data)
self[incident.report_id] = incident
except (ValueError, LookupError, IncidentError) as err:
print("{0}: import error: {1}".format(
os.path.basename(sys.argv[0]), err))
return False
return True
DOM存在后,我们清空当前的事故记录数据,并对所有事故标签进行迭代。每次迭代时,我们都提取其属性,对日期、数值型以及布尔型等数据,我们都将其转换为适当的类型,就像使用元素树时所做的一样。使用DOM与使用元素树之间真正较大的区别是对文本节点的处理过程,我们使用xml.dom.Element.getElementsByTagName()方法获取给定标签名的子元素——对与,我们知道总是会有其中的一个,因此我们取每个类型的第一个(唯一的一个),之后使用嵌套的get_text()函数对这些标签的子节点进行迭代,以便提取其文本。
与通常一样,如果有任何错误产生,我们就将捕获相关的异常,为用户打印错误消息,并返回False。
DOM与元素树方法之间的差别并不大,由于两者都使用同样的expat分析器,因此两者都非常快。
7.3.3 手动写入XML
将预存的元素树或DOM写成XML文档可以使用单独的方法调用完成。如果数据本身不是以这两种形式存在,我们就必须先创建元素树或DOM,之后直接写出数据会更加方便。
写XML文件时,我们必须确保正确地对文本与属性值进行了转义处理,并且写的是格式正确的XML文档。下面给出export_xml_manual()方法,该方法用于以XML格式写出事故数据。
def export_xml_manual(self, filename):
fh = None
try:
fh = open(filename, "w", encoding="utf8")
fh.write('<?xml version="1.0" encoding="UTF-8"?>\n')
fh.write("<incidents>\n")
for incident in self.values():
fh.write('<incident report_id={report_id} '
'date="{0.date!s}" '
'aircraft_id={aircraft_id} '
'aircraft_type={aircraft_type} '
'pilot_percent_hours_on_type='
'"{0.pilot_percent_hours_on_type}" '
'pilot_total_hours="{0.pilot_total_hours}" '
'midair="{0.midair:d}">\n'
'<airport>{airport}</airport>\n'
'<narrative>\n{narrative}\n</narrative>\n'
'</incident>\n'.format(incident,
report_id=xml.sax.saxutils.quoteattr(
incident.report_id),
aircraft_id=xml.sax.saxutils.quoteattr(
incident.aircraft_id),
aircraft_type=xml.sax.saxutils.quoteattr(
incident.aircraft_type),
airport=xml.sax.saxutils.escape(incident.airport),
narrative="\n".join(textwrap.wrap(
xml.sax.saxutils.escape(
incident.narrative.strip()), 70))))
fh.write("</incidents>\n")
return True
正如本章中我们通常所做的一样,我们也忽略了except语句块与finally语句块。
我们使用UTF-8编码写文件,并且必须为内置的open()函数指定该编码方式。严格地说,我们并不需要在<?xml?> 声明中指定该编码,因为UTF-8是默认的编码格式,但我们更愿意清晰地指定。我们选择使用双引号(")来封装所有属性值,并且,为方便起见,我们使用单引号来封装事故数据中的字符串,以避免对引号进行转义处理的需要。
sax.saxutils.quoteattr()函数与sax.saxutils.escape()函数(我们使用这一函数处理XML文本,因为该函数可以正确地对“&”、“<”、“>”等字符进行转义处理)类似,此外,该函数还可以对引号进行转义(如果需要),并返回已经使用引号包含了的字符串 ,这也是为什么我们不需要对报告ID以及其他字符串属性值加引号的原因所在。
叙述性文本中插入的换行与文本包裹纯粹是为了装饰用的,其目的是为了使其更便于人的阅读和编辑,但也可以忽略。
以HTML格式写数据与以XML格式并没有太大的差别。convert-incidents.py程序包含的export_html()函数是一个简单的实例,这里没有给出该函数,因为其中没有什么新东西。
7.3.4 使用SAX分析XML
与元素树和DOM在内存中表示整个XML文档不同的是,SAX分析器是逐步读入并处理的,从而可能更快,对内存的需求也不那么明显。然而,性能上的优势不能仅靠假设,尤其是元素树与DOM都使用了快速的expat分析器。
在遇到开始标签、结束标签以及其他XML元素时,SAX分析器宣称“分析事件”并进行工作。为处理那些我们感兴趣的事件,我们必须创建一个适当的处理者类,并提供某些预定义的方法,在匹配分析事件发生时,就会调用这些方法。最常实现的处理者是内容处理者,当然,如果我们需要更好的控制,提供错误处理者以及其他处理者也是可能的。
下面给出的是完整的import_xml_sax()方法,由于大部分工作都已经由自定义的IncidentSaxHandler类实现,因此,这一方法的实现代码很短。
def import_xml_sax(self, filename):
fh = None
try:
handler = IncidentSaxHandler(self)
parser = xml.sax.make_parser()
parser.setContentHandler(handler)
parser.parse(filename)
return True
except (EnvironmentError, ValueError, IncidentError,
xml.sax.SAXParseException) as err:
print("{0}: import error: {1}".format(
os.path.basename(sys.argv[0]), err))
return False
我们首先创建了要使用的处理者,之后创建一个SAX分析器,并将其内容处理者设置为我们刚创建的那个。之后,我们将文件名赋予分析器的parse()方法,如果没有分析错误产生,就返回True。
我们将self(也就是说,这个IncidentCollection dict子类)传递给自定义的IncidentSaxHandler类的初始化程序。处理者清空旧的事故记录,之后随着对文件分析的进程建立起一个事故字典。分析完成后,该字典将包含读入的所有事故。
class IncidentSaxHandler(xml.sax.handler.ContentHandler):
def __init__(self, incidents):
super().__init__()
self.__data = {}
self.__text = ""
self.__incidents = incidents
self.__incidents.clear()
自定义的SAX处理者类必须继承自适当的基类,这将确保对于任何我们没有重新实现的方法(因为我们不关心这些方法处理的分析事件),都会调用该方法的基类版本,并且实际上不做任何处理。
我们首先调用基类的初始化程序。对所有子类而言,这通常是一种好的做法,尽管对直接的object子类而言这样做没有必要(但也没有坏处)。字典self.__data用于保存某个事故的数据,self.__text字符串用于存放机场名的文本信息或叙述性文本的文本信息,这依赖于我们当前正在读入的具体内容,self.__incidents字典是到IncidentCollec-tion字典(对这一字典,处理者直接对其进行更新操作)的对象引用。(一种替代的设计方案是将一个独立的字典放置在处理者内部,并在最后使用dict.clear()将其复制到IncidentCollection,之后调用dict.update()。)
def startElement(self, name, attributes):
if name == "incident":
self.__data = {}
for key, value in attributes.items():
if key == "date":
self.__data[key] = datetime.datetime.strptime(
value, "%Y-%m-%d").date()
elif key == "pilot_percent_hours_on_type":
self.__data[key] = float(value)
elif key == "pilot_total_hours":
self.__data[key] = int(value)
elif key == "midair":
self.__data[key] = bool(int(value))
else:
self.__data[key] = value
self.__text = ""
在读取到开始标签及其属性的任何时候,都会以标签名以及标签属性作为参数来调用xml.sax.handler.Content-Handler.startElement()方法。对航空器事故XML文件,开始标签是,我们将忽略该标签;标签,我们使用其属性来生成self.__data字典的一部分;标签与标签,两者我们都忽略。在读取到开始标签时,我们总是清空self.__text字符串,因为在航空器事故XML文件格式中,没有嵌套的文本标签。
在IncidentSaxHandler类中,我们没有进行任何异常处理。如果产生异常,就将传递给调用者,这里也就是import_xml_sax()方法,调用者将捕获异常,并输出适当的错误消息。
def endElement(self, name):
if name == "incident":
if len(self.__data) != 9:
raise IncidentError("missing data")
incident = Incident(**self.__data)
self.__incidents[incident.report_id] = incident
elif name in frozenset({"airport", "narrative"}):
self.__data[name] = self.__text.strip()
self.__text = ""
读取到结束标签时,将调用xml.sax.handler.ContentHandler.endElement()方法。如果已经到达某条事故记录的结尾,此时应该已具备所有必要的数据,因此,此时创建一个新的Incident对象,并将其添加到事故字典。如果已到达文本元素的结尾,就像self.__data 字典中添加一个项(其中包含迄今为止累积的文本)。最后,我们清空self.__text字符串,以备后面使用。(严格地说,我们也没必要对其进行清空,因为在获取开始标签时也可以清空该字符串,但对有些XML格式,清空该字符串会有一定的作用,比如对标签可以嵌套的情况。)
def characters(self, text):
self.__text += text
读取到文本时,SAX分析器将调用xml.sax.handler.ContentHandler.characters()方法,但并不能保证对所有文本只调用一次该方法,因为文本可能以分块的形式出现,这也是为什么我们只是简单地使用该方法来累积文本,而只有在读取到相关的结束标签后才真正将文本放置到字典中。(一种更高效的实现方案是将self.__text作为一个列表,这一方法的主体部分则使用self.__text.append(text),其他方法也相应调整。)
与使用元素树或DOM相比,使用SAX API是非常不同的,但确实也是很有效的。我们可以提供其他处理者,并在内容处理者中重新实现额外的方法,以便按我们的需要施加更多的控制。SAX分析器本身并不保存XML文档的任意表示形式——这使得SAX适合于将XML读入到我们的自定义数据组合中,也意味着没有SAX“文档”以XML格式写出,因此,对写XML而言,我们必须使用本章前面描述的某种方法。