关于python:xml.etree.ElementTree与lxml.etree:不同的内部节点表示形式?

xml.etree.ElementTree vs. lxml.etree: different internal node representation?

我一直在将某些原始的xml.etree.ElementTree(ET)代码转换为lxml.etree(lxmlET)。幸运的是,两者之间有很多相似之处。但是,我确实偶然发现了一些奇怪的行为,这些行为在任何文档中都找不到。它考虑了后代节点的内部表示。

在ET中,iter()用于遍历Element的所有后代,可以选择按标记名过滤。因为我在文档中找不到关于此的任何详细信息,所以我期望lxmlET具有类似的行为。事实是,从测试中我得出结论,在lxmlET中,树的内部表示形式有所不同。

在下面的示例中,我遍历树中的节点并打印每个节点的子代,但此外,我还创建了这些子代的所有不同组合并打印了它们。这意味着,如果元素具有子元素('A', 'B', 'C'),我将创建更改,即树[('A'), ('A', 'B'), ('A', 'C'), ('B'), ('B', 'C'), ('C')]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# import lxml.etree as ET
import xml.etree.ElementTree as ET
from itertools import combinations
from copy import deepcopy


def get_combination_trees(tree):
    children = list(tree)
    for i in range(1, len(children)):
        for combination in combinations(children, i):
            new_combo_tree = ET.Element(tree.tag, tree.attrib)
            for recombined_child in combination:
                new_combo_tree.append(recombined_child)
                # when using lxml a deepcopy is required to make this work (or make change in parse_xml)
                # new_combo_tree.append(deepcopy(recombined_child))
            yield new_combo_tree

    return None


def parse_xml(tree_p):
    for node in ET.fromstring(tree_p):
        if not node.tag == 'node_main':
            continue
        # replace by node.xpath('.//node') for lxml (or use deepcopy in get_combination_trees)
        for subnode in node.iter('node'):
            children = list(subnode)
            if children:
                print('-'.join([child.attrib['id'] for child in children]))
            else:
                print(f'node {subnode.attrib["id"]} has no children')

            for combo_tree in get_combination_trees(subnode):
                combo_children = list(combo_tree)
                if combo_children:
                    print('-'.join([child.attrib['id'] for child in combo_children]))    

    return None


s = '''<root>
  <node_main>
    <node id="1">
      <node id="2" />
      <node id="3">
        <node id="4">
          <node id="5" />
        </node>
        <node id="6" />
      </node>
    </node>
  </node_main>
</root>
'''


parse_xml(s)

此处的预期输出是通过连字符连在一起的每个节点的子代的id,以及所有子集的所有可能组合(参见上文)(以自上而下的广度优先的方式)。

1
2
3
4
5
6
7
8
9
10
2-3
2
3
node 2 has no children
4-6
4
6
5
node 5 has no children
node 6 has no children

但是,当使用lxml模块而不是xml(取消注释lxmlET的导入并注释ET的导入)并运行代码时,您将看到输出为

1
2
3
4
2-3
2
3
node 2 has no children

因此,永远不会访问更深的后代节点。可以通过以下任一方法来规避:

  • 使用deepcopy(get_combination_trees()中的注释/取消注释相关部分),或
  • parse_xml()中使用for subnode in node.xpath('.//node')而不是iter()
  • 所以我知道有办法解决这个问题,但是我主要想知道正在发生什么?!我花了很长时间调试它,但找不到任何文档。这是怎么回事,这两个模块之间的实际根本区别是什么?在处理非常大的树木时,最有效的解决方法是什么?


    尽管Louis的答案是正确的,并且我完全同意在遍历数据结构时对其进行修改通常是一个坏主意(tm),但您还询问了为什么代码使用xml.etree.ElementTree而不是lxml.etree进行操作,并且有一个非常合理的解释那。

    xml.etree.ElementTree.append的实现

    该库是直接在Python中实现的,并且可能会根据所使用的Python运行时而有所不同。假设您正在使用CPython,则要查找的实现是在普通Python中实现的:

    1
    2
    3
    4
    5
    6
    7
    8
    def append(self, subelement):
       """Add *subelement* to the end of this element.
        The new element will appear in document order after the last existing
        subelement (or directly after the text, if it's the first subelement),
        but before the end tag for this element.
       """

        self._assert_is_element(subelement)
        self._children.append(subelement)

    最后一行是我们关心的唯一部分。事实证明,self._children初始化为该文件的顶部为:

    1
    self._children = []

    因此,将孩子添加到树中只是将元素添加到列表中。直观地讲,这正是您想要的(在这种情况下),实现的行为完全不令人惊讶。

    lxml.etree中的实现.append

    lxml被实现为Python,非平凡的Cython和C代码的混合体,因此通过它进行拼写比纯Python实现要困难得多。首先,.append实现为:

    1
    2
    3
    4
    5
    6
    7
    def append(self, _Element element not None):
        u"""append(self, element)
        Adds a subelement to the end of this element.
       """

        _assertValidNode(self)
        _assertValidNode(element)
        _appendChild(self, element)

    _appendChildapihelper.pxi中实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    cdef int _appendChild(_Element parent, _Element child) except -1:
        u"""Append a new child to a parent element.
       """

        c_node = child._c_node
        c_source_doc = c_node.doc
        # prevent cycles
        if _isAncestorOrSame(c_node, parent._c_node):
            raise ValueError("cannot append parent to itself")
        # store possible text node
        c_next = c_node.next
        # move node itself
        tree.xmlUnlinkNode(c_node)
        tree.xmlAddChild(parent._c_node, c_node)
        _moveTail(c_next, c_node)
        # uh oh, elements may be pointing to different doc when
        # parent element has moved; change them too..
        moveNodeToDocument(parent._doc, c_source_doc, c_node)
        return 0

    这里肯定还有更多事情。特别地,lxml从树中显式删除该节点,然后将其添加到其他位置。这样可以防止您在操纵节点时意外创建循环XML图(这可能是xml.etree版本可以执行的操作)。

    lxml的解决方法

    现在我们知道xml.etree在添加节点时会复制节点,但是lxml.etree会移动它们,为什么这些替代方法有效?基于tree.xmlUnlinkNode方法(实际上是在libxml2的C语言中定义的),取消链接只会导致一堆指针混乱。因此,任何复制节点元数据的方法都可以解决问题。因为我们关心的所有元数据都是xmlNode结构上的直接字段,所以任何浅表副本节点都可以解决问题

    • copy.deepcopy()绝对有效
    • node.xpath返回包裹在代理元素中的节点,这些节点恰好浅复制了树元数据
    • copy.copy()也能解决问题
    • 如果您不需要组合实际上位于正式树中,则设置new_combo_tree = []也会像xml.etree一样为您提供列表附加。

    如果您确实关心性能和大树,我可能会先从copy.copy()浅拷贝开始,尽管您应该绝对介绍几个不同的选项,然后看看哪个最适合您。


    复制问题

    通常,在处理XML树并希望在树中的多个位置复制信息(反对将信息从一个位置移动到另一位置)时,安全的做法是对这些元素执行深度复制操作,而不是只需将它们添加到新位置即可。如果要复制结构,绝大多数生成树的XML解析库都要求您执行深层复制。如果您不进行深层复制,它们只会为您提供所需的结果。 lxml是一个这样的库,它要求您深度复制要复制的结构。

    根据我的经验,xml.etree.ElementTree的工作方式可以有效地使.append允许您在树中的两个位置使用相同的元素。

    边走边改善问题

    您提到for subnode in node.xpath('.//node')也可以解决您的问题。请注意,如果使用for subnode in list(node.iter('node')),将得到相同的结果。这里发生的是使用list(node.iter('node'))node.xpath('.//node')或使用deepcopy复制节点而不是移动节点,而不是移动它们可以保护您免受代码的另一个问题:在修改结构时要遍历结构。

    node.iter('node')创建一个迭代器,该迭代器在您迭代XML结构时会遍历它。如果将其包装在list()中,则会立即遍历该结构并将结果放入列表中。因此,在遍历结构之前,您已经有效地拍摄了该结构的快照。这样可以防止您的步行操作受到树的更改的影响。如果执行node.xpath('.//node'),则在遍历树之前还需要获取树的快照,因为该方法将返回节点列表。而且,如果您对节点执行deepcopy并附加节点的副本而不是附加原始节点,那么您在行走时不会修改正在行走的树。

    使用XPath还是使用node.xpath('.//node')而不是deepcopy是可以解决的,取决于您打算对组合进行的操作。创建问题后,您在问题中显示的代码会将组合打印到屏幕上。当您打印它们时,它们看起来很好,但是如果您不使用deepcopy来创建它们,那么一旦您创建了一个新组合,旧组合就会变得混乱,因为任何出现在旧组合中的节点都需要出现在新的位置将被移动而不是复制。

    And what is the most efficient work-around when working with very large trees?

    这取决于您的应用程序的详细信息以及您需要解析的数据。您给出了一个小文件示例,但您询问了"大树"。适用于小文件的内容不一定会转移到大文件。您可以针对案例X进行优化,但是如果案例X仅在极少的实际数据中发生,那么您的优化可能不会成功。在某些情况下,它实际上可能是有害的。

    在我的一个应用中,我不得不用结构本身替换对某些结构的引用。一个简化的插图是一个包含...之类的元素和之类的引用的文档。 ref的每个实例都必须替换为其所指向的define。通常,这可能意味着多次复制一个define,但有时一个ref可能仅引用一个define,因此一种优化是检测到此错误,并在只有一个引用的情况下跳过深层复制。我免费获得了此优化,因为该应用程序已经需要记录refdefine的每个实例以用于其他目的。如果我只是为了优化而不得不添加簿记功能,那还不值得。


    一开始我并不认为有什么区别(我也没有检查),但是@ supersam654和@Louis答案都非常清楚地指出了这一点。

    但是,依赖于它所使用的东西的内部表示(而不是接口)的代码在我看来(从设计PoV来看)并不正确。另外,正如我在评论中问的那样:combo_children似乎完全没有用:

  • 获取子节点组合(作为列表)
  • 将列表中的每个节点作为子项追加到combo_children
  • 返回combo_children
  • 获取combo_children的孩子(作为列表)
  • 使用列表(组合)
  • 当事情可以轻松完成时:

  • 获取子节点组合(作为列表)
  • 返回清单
  • 使用列表(组合)
  • 显然,combo_children方法还暴露了模块之间的行为差??异。

    code_orig_lxml.py:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    import lxml.etree as ET
    #import xml.etree.ElementTree as ET
    from itertools import combinations
    from copy import deepcopy


    def get_combination_trees(tree):
        children = list(tree)
        for i in range(1, len(children)):
            for combination in combinations(children, i):
                #new_combo_tree = ET.Element(tree.tag, tree.attrib)
                #for recombined_child in combination:
                    #new_combo_tree.append(recombined_child)
                    # when using lxml a deepcopy is required to make this work (or make change in parse_xml)
                    # new_combo_tree.append(deepcopy(recombined_child))
                #yield new_combo_tree
                yield combination

        return None


    def parse_xml(tree_p):
        for node in ET.fromstring(tree_p):
            if not node.tag == 'node_main':
                continue
            # replace by node.xpath('.//node') for lxml (or use deepcopy in get_combination_trees)
            for subnode in node.iter('node'):
                children = list(subnode)
                if children:
                    print('-'.join([child.attrib['id'] for child in children]))
                else:
                    print(f'node {subnode.attrib["id"]} has no children')

                #for combo_tree in get_combination_trees(subnode):
                for combo_children in get_combination_trees(subnode):
                    #combo_children = list(combo_tree)
                    if combo_children:
                        print('-'.join([child.attrib['id'] for child in combo_children]))

        return None


    s ="""
    <root>
      <node_main>
        <node id="1">
          <node id="2" />
          <node id="3">
            <node id="4">
              <node id="5" />
            </node>
            <node id="6" />
          </node>
        </node>
      </node_main>
    </root>
    """


    parse_xml(s)

    笔记:

    • 这是上面更改的代码
    • 我没有删除任何内容,而是仅评论了内容(这将在新旧版本之间产生最小的差异)

    输出:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    (py36x86_test) e:\Work\Dev\StackOverflow\q050749937>"e:\Work\Dev\VEnvs\py36x86_test\Scripts\python.exe" code_orig_lxml.py
    2-3
    2
    3
    node 2 has no children
    4-6
    4
    6
    5
    node 5 has no children
    node 6 has no children

    在调查期间,我进一步修改了代码,以:

    • 解决问题
    • 改善印刷
    • 使其模块化
    • 使用两种解析方法,使它们之间的差异更加清晰

    xml_data.py:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    DATA ="""
    <root>
      <node_main>
        <node id="1">
          <node id="2" />
          <node id="3">
            <node id="4">
              <node id="5" />
            </node>
            <node id="6" />
          </node>
        </node>
      </node_main>
    </root>
    """

    code.py:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    import sys
    import xml.etree.ElementTree as xml_etree_et
    import lxml.etree as lxml_etree
    from itertools import combinations
    from xml_data import DATA


    MAIN_NODE_NAME ="node_main"


    def get_children_combinations(tree):
        children = list(tree)
        for i in range(1, len(children)):
            yield from combinations(children, i)


    def get_tree(xml_str, parse_func, tag=None):
        root_node = parse_func(xml_str)
        if tag:
            return [item for item in root_node if item.tag == tag]
        return [root_node]


    def process_xml(xml_node):
        for node in xml_node.iter("node"):
            print(f"
    Node ({node.tag}, {node.attrib['id']})"
    )
            children = list(node)
            if children:
                print("    Children:" +" -".join([child.attrib["id"] for child in children]))

            for children_combo in get_children_combinations(node):
                if children_combo:
                    print("    Combo:" +" -".join([child.attrib["id"] for child in children_combo]))


    def main():
        parse_funcs = (xml_etree_et.fromstring, lxml_etree.fromstring)
        for func in parse_funcs:
            print(f"
    Parsing xml using: {func.__module__} {func.__name__}"
    )
            nodes = get_tree(DATA, func, tag=MAIN_NODE_NAME)
            for node in nodes:
                print(f"
    Processing node: {node.tag}"
    )
                process_xml(node)


    if __name__ =="__main__":
        print("Python {:s} on {:s}
    "
    .format(sys.version, sys.platform))
        main()

    输出:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    (py36x86_test) e:\Work\Dev\StackOverflow\q050749937>"e:\Work\Dev\VEnvs\py36x86_test\Scripts\python.exe" code.py
    Python 3.6.2 (v3.6.2:5fd33b5, Jul  8 2017, 04:14:34) [MSC v.1900 32 bit (Intel)] on win32


    Parsing xml using: xml.etree.ElementTree XML

    Processing node: node_main

    Node (node, 1)
        Children: 2 - 3
        Combo: 2
        Combo: 3

    Node (node, 2)

    Node (node, 3)
        Children: 4 - 6
        Combo: 4
        Combo: 6

    Node (node, 4)
        Children: 5

    Node (node, 5)

    Node (node, 6)

    Parsing xml using: lxml.etree fromstring

    Processing node: node_main

    Node (node, 1)
        Children: 2 - 3
        Combo: 2
        Combo: 3

    Node (node, 2)

    Node (node, 3)
        Children: 4 - 6
        Combo: 4
        Combo: 6

    Node (node, 4)
        Children: 5

    Node (node, 5)

    Node (node, 6)