Skip to content

Commit 5eee461

Browse files
gh-68475: Keep comments and processing instructions outside the root element
ElementTree gets the children attribute, a view of the children of the document, containing the root element and any number of comments and processing instructions around it. Adding a second element is an error. iter() iterates over all of them, but find(), findall() and iterfind() still search from the root element. TreeBuilder collects the comments and processing instructions which occur outside the root element and returns them, together with the root element, from the new document() method. This only happens when insert_comments or insert_pis is set, so nothing changes for existing code. parse() asks the target for the document before close(), which releases it. The C accelerator implements document() too, so that the feature works at full parsing speed.
1 parent 26d9b25 commit 5eee461

6 files changed

Lines changed: 459 additions & 29 deletions

File tree

Doc/library/xml.etree.elementtree.rst

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,6 +1135,34 @@ ElementTree Objects
11351135
of the XML *file* if given.
11361136

11371137

1138+
.. attribute:: children
1139+
1140+
A sequence of the children of the document:
1141+
the root element and the comments and processing instructions
1142+
which surround it.
1143+
It can contain at most one element,
1144+
which is the root element of the tree;
1145+
adding a second one raises :exc:`ValueError`,
1146+
and adding anything which is not an element raises :exc:`TypeError`.
1147+
1148+
It supports ``len()``, iteration, the :keyword:`in` operator,
1149+
:func:`reversed`, indexing and slicing (for getting, setting and
1150+
deleting), and the methods :meth:`!append`, :meth:`!insert`,
1151+
:meth:`!extend`, :meth:`!remove` and :meth:`!clear`,
1152+
which have the same signatures as the methods of :class:`list`.
1153+
It is a view of the tree: it changes when the tree changes,
1154+
and changing it changes the tree.
1155+
1156+
Comments and processing instructions are only added to it when parsing
1157+
if the parser target collects them; see :class:`TreeBuilder`.
1158+
1159+
:meth:`iter` iterates over all children of the document,
1160+
but :meth:`find`, :meth:`findall` and :meth:`iterfind`
1161+
search from the root element, so they never return the other children.
1162+
1163+
.. versionadded:: next
1164+
1165+
11381166
.. method:: _setroot(element)
11391167

11401168
Replaces the root element for this tree. This discards the current
@@ -1164,9 +1192,14 @@ ElementTree Objects
11641192

11651193
.. method:: iter(tag=None)
11661194

1167-
Creates and returns a tree iterator for the root element. The iterator
1168-
loops over all elements in this tree, in section order. *tag* is the tag
1169-
to look for (default is to return all elements).
1195+
Creates and returns a tree iterator for the document.
1196+
The iterator loops over all children of the document
1197+
and their descendants, in document order.
1198+
*tag* is the tag to look for (default is to return all elements).
1199+
1200+
.. versionchanged:: next
1201+
It iterates over all children of the document,
1202+
not only over the root element and its descendants.
11701203

11711204

11721205
.. method:: iterfind(match, namespaces=None)
@@ -1286,7 +1319,12 @@ TreeBuilder Objects
12861319
create comments and processing instructions. When not given, the default
12871320
factories will be used. When *insert_comments* and/or *insert_pis* is true,
12881321
comments/pis will be inserted into the tree if they appear within the root
1289-
element (but not outside of it).
1322+
element. Those which appear outside of it are returned by
1323+
:meth:`document`.
1324+
1325+
.. versionchanged:: next
1326+
Comments and processing instructions outside the root element
1327+
are no longer discarded.
12901328

12911329
.. method:: close()
12921330

@@ -1300,6 +1338,16 @@ TreeBuilder Objects
13001338
either a bytestring, or a Unicode string.
13011339

13021340

1341+
.. method:: document()
1342+
1343+
Returns the children of the document:
1344+
the root element, and the comments and processing instructions
1345+
which were inserted outside of it.
1346+
Returns a list of :class:`Element` instances.
1347+
1348+
.. versionadded:: next
1349+
1350+
13031351
.. method:: end(tag)
13041352

13051353
Closes the current element. *tag* is the element name. Returns the

Doc/whatsnew/3.16.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,16 @@ xml
642642
and :meth:`!Document.createEntityReference`.
643643
(Contributed by Jason Orendorff and Serhiy Storchaka in :gh:`44871`.)
644644

645+
* Comments and processing instructions which occur outside of the root element
646+
are no longer lost in :mod:`xml.etree.ElementTree`.
647+
:class:`~xml.etree.ElementTree.ElementTree` now has the
648+
:attr:`~xml.etree.ElementTree.ElementTree.children` attribute,
649+
a sequence of the children of the document,
650+
and :class:`~xml.etree.ElementTree.TreeBuilder` returns them
651+
from the new :meth:`!document` method
652+
when *insert_comments* or *insert_pis* is true.
653+
(Contributed by Serhiy Storchaka in :gh:`68475`.)
654+
645655
* Add :meth:`!GetSpecifiedAttributeCount` method
646656
to the :mod:`XML parser <xml.parsers.expat>` objects.
647657
It tells how many of the reported attributes were given in the start tag

Lib/test/test_xml_etree.py

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3739,7 +3739,7 @@ def test_basic(self):
37393739
self.assertEqual(next(ET.iterparse(sourcefile, parser=parser))[0], 'end')
37403740

37413741
tree = ET.ElementTree(None)
3742-
self.assertRaises(AttributeError, tree.iter)
3742+
self.assertEqual(list(tree.iter()), [])
37433743

37443744
# Issue #16913
37453745
doc = ET.XML("<root>a&amp;<sub>b&amp;</sub>c&amp;</root>")
@@ -3836,6 +3836,179 @@ def test_pickle(self):
38363836
pickle.dumps(it, proto)
38373837

38383838

3839+
3840+
class DocumentChildrenTest(unittest.TestCase):
3841+
# gh-68475: comments and processing instructions outside the root element
3842+
3843+
sample = ('<!--lead--><?pi data?><r><?in?><a/></r><?after?><!--tail-->')
3844+
3845+
def parse(self, text=None):
3846+
builder = ET.TreeBuilder(insert_comments=True, insert_pis=True)
3847+
tree = ET.ElementTree()
3848+
tree.parse(io.StringIO(text if text is not None else self.sample),
3849+
ET.XMLParser(target=builder))
3850+
return tree
3851+
3852+
def test_only_the_root_by_default(self):
3853+
tree = ET.ElementTree()
3854+
tree.parse(io.StringIO(self.sample))
3855+
self.assertEqual(summarize_list(tree.children), ['r'])
3856+
self.assertEqual(len(tree.children), 1)
3857+
self.assertIs(tree.children[0], tree.getroot())
3858+
3859+
def test_parse_keeps_the_prolog_and_the_epilog(self):
3860+
tree = self.parse()
3861+
self.assertEqual(summarize_list(tree.children),
3862+
[ET.Comment, ET.ProcessingInstruction, 'r',
3863+
ET.ProcessingInstruction, ET.Comment])
3864+
self.assertEqual(tree.children[0].text, 'lead')
3865+
self.assertEqual(tree.children[-1].text, 'tail')
3866+
self.assertIs(tree.getroot(), tree.children[2])
3867+
3868+
def test_write(self):
3869+
tree = self.parse()
3870+
file = io.StringIO()
3871+
tree.write(file, encoding='unicode')
3872+
self.assertEqual(file.getvalue(),
3873+
'<!--lead--><?pi data?><r><?in?><a /></r>'
3874+
'<?after?><!--tail-->')
3875+
3876+
def test_iter(self):
3877+
tree = self.parse()
3878+
self.assertEqual(summarize_list(tree.iter()),
3879+
[ET.Comment, ET.ProcessingInstruction, 'r',
3880+
ET.ProcessingInstruction, 'a',
3881+
ET.ProcessingInstruction, ET.Comment])
3882+
self.assertEqual(summarize_list(tree.iter('*')),
3883+
[ET.Comment, ET.ProcessingInstruction, 'r',
3884+
ET.ProcessingInstruction, 'a',
3885+
ET.ProcessingInstruction, ET.Comment])
3886+
self.assertEqual(summarize_list(tree.iter('a')), ['a'])
3887+
# comments and processing instructions can be selected by the factory
3888+
self.assertEqual(summarize_list(tree.iter(ET.ProcessingInstruction)),
3889+
[ET.ProcessingInstruction] * 3)
3890+
self.assertEqual(summarize_list(tree.iter(ET.Comment)),
3891+
[ET.Comment, ET.Comment])
3892+
3893+
def test_find_searches_from_the_root(self):
3894+
tree = self.parse()
3895+
# find() and friends search from the root element, so they return
3896+
# the processing instruction inside it, but not those outside
3897+
self.assertEqual(summarize_list(tree.findall('*')),
3898+
[ET.ProcessingInstruction, 'a'])
3899+
self.assertEqual(summarize_list(tree.findall('.//*')),
3900+
[ET.ProcessingInstruction, 'a'])
3901+
self.assertEqual(tree.find('a').tag, 'a')
3902+
3903+
def test_append_and_insert(self):
3904+
tree = ET.ElementTree(ET.Element('r'))
3905+
tree.children.insert(0, ET.Comment('lead'))
3906+
tree.children.append(ET.ProcessingInstruction('pi', 'data'))
3907+
self.assertEqual(summarize_list(tree.children),
3908+
[ET.Comment, 'r', ET.ProcessingInstruction])
3909+
self.assertIs(tree.getroot(), tree.children[1])
3910+
3911+
def test_the_first_element_becomes_the_root(self):
3912+
tree = ET.ElementTree()
3913+
tree.children.append(ET.Comment('lead'))
3914+
self.assertIsNone(tree.getroot())
3915+
elem = ET.Element('r')
3916+
tree.children.append(elem)
3917+
self.assertIs(tree.getroot(), elem)
3918+
3919+
def test_only_one_element(self):
3920+
tree = ET.ElementTree(ET.Element('r'))
3921+
children = tree.children
3922+
children.insert(0, ET.Comment('lead'))
3923+
self.assertRaises(ValueError, children.append, ET.Element('second'))
3924+
self.assertRaises(ValueError, children.insert, 0, ET.Element('second'))
3925+
self.assertRaises(ValueError, children.extend, [ET.Element('second')])
3926+
# the comment cannot be replaced by an element either
3927+
self.assertRaises(ValueError, children.__setitem__, 0,
3928+
ET.Element('second'))
3929+
self.assertEqual(summarize_list(tree.children), [ET.Comment, 'r'])
3930+
self.assertEqual(tree.getroot().tag, 'r')
3931+
# but the root element can be replaced
3932+
children[1] = ET.Element('other')
3933+
self.assertEqual(tree.getroot().tag, 'other')
3934+
3935+
def test_not_an_element(self):
3936+
tree = ET.ElementTree(ET.Element('r'))
3937+
self.assertRaises(TypeError, tree.children.append, 'text')
3938+
self.assertRaises(TypeError, tree.children.insert, 0, None)
3939+
self.assertEqual(summarize_list(tree.children), ['r'])
3940+
3941+
def test_remove_and_delete(self):
3942+
tree = self.parse()
3943+
root = tree.getroot()
3944+
tree.children.remove(root)
3945+
self.assertIsNone(tree.getroot())
3946+
self.assertEqual(summarize_list(tree.children),
3947+
[ET.Comment, ET.ProcessingInstruction,
3948+
ET.ProcessingInstruction, ET.Comment])
3949+
del tree.children[0]
3950+
self.assertEqual(summarize_list(tree.children),
3951+
[ET.ProcessingInstruction, ET.ProcessingInstruction,
3952+
ET.Comment])
3953+
tree.children.clear()
3954+
self.assertEqual(summarize_list(tree.children), [])
3955+
self.assertIsNone(tree.getroot())
3956+
3957+
def test_slices(self):
3958+
tree = self.parse()
3959+
root = tree.getroot()
3960+
tree.children[0:2] = [ET.Comment('one')]
3961+
self.assertEqual(summarize_list(tree.children),
3962+
[ET.Comment, 'r', ET.ProcessingInstruction,
3963+
ET.Comment])
3964+
self.assertIs(tree.getroot(), root)
3965+
# the slice which replaces the root element can add another one
3966+
new = ET.Element('new')
3967+
tree.children[1:2] = [new]
3968+
self.assertIs(tree.getroot(), new)
3969+
# but not two
3970+
self.assertRaises(ValueError, tree.children.__setitem__,
3971+
slice(0, 2), [ET.Element('a'), ET.Element('b')])
3972+
self.assertIs(tree.getroot(), new)
3973+
del tree.children[1:2]
3974+
self.assertIsNone(tree.getroot())
3975+
3976+
def test_the_root_cannot_be_a_comment(self):
3977+
self.assertRaises(ValueError, ET.ElementTree, ET.Comment('c'))
3978+
self.assertRaises(ValueError, ET.ElementTree,
3979+
ET.ProcessingInstruction('pi'))
3980+
tree = ET.ElementTree(ET.Element('r'))
3981+
self.assertRaises(ValueError, tree._setroot, ET.Comment('c'))
3982+
3983+
def test_tostring_of_a_comment(self):
3984+
# tostring() serializes a single node, which can be a comment
3985+
self.assertEqual(ET.tostring(ET.Comment('c')), b'<!--c-->')
3986+
self.assertEqual(ET.tostring(ET.ProcessingInstruction('t', 'd')),
3987+
b'<?t d?>')
3988+
3989+
def test_document_without_the_root(self):
3990+
tree = ET.ElementTree()
3991+
tree.children.extend([ET.Comment('a'), ET.ProcessingInstruction('p')])
3992+
file = io.StringIO()
3993+
tree.write(file, encoding='unicode')
3994+
self.assertEqual(file.getvalue(), '<!--a--><?p?>')
3995+
3996+
def test_builder_document(self):
3997+
builder = ET.TreeBuilder(insert_comments=True, insert_pis=True)
3998+
parser = ET.XMLParser(target=builder)
3999+
parser.feed(self.sample)
4000+
root = parser.close()
4001+
self.assertEqual(root.tag, 'r')
4002+
self.assertEqual(len(builder.document()), 5)
4003+
self.assertIs(builder.document()[2], root)
4004+
4005+
def test_builder_document_without_inserting(self):
4006+
builder = ET.TreeBuilder()
4007+
parser = ET.XMLParser(target=builder)
4008+
parser.feed(self.sample)
4009+
root = parser.close()
4010+
self.assertEqual(builder.document(), [root])
4011+
38394012
class TreeBuilderTest(unittest.TestCase):
38404013
sample1 = ('<!DOCTYPE html PUBLIC'
38414014
' "-//W3C//DTD XHTML 1.0 Transitional//EN"'

0 commit comments

Comments
 (0)