Skip to content

Commit a8148ba

Browse files
gh-63102: Add XMLPullTarget and XMLPullParser.expand()
XMLPullTarget is a parser target for XMLPullParser and iterparse() which reports elements without building a tree. The reported object is an Element with the tag, the attributes and, for the "end" event, the text, but without children: they are reported as their own events. Nothing is kept, so a document of any size can be parsed with a constant amount of memory, like with xml.dom.pulldom, but much faster. XMLPullParser.expand() builds the subtree of a reported element from the events which are already read from the parser, like expandNode() in pulldom. The iterator returned by iterparse() has the same method which reads more data if needed.
1 parent a2f4f19 commit a8148ba

5 files changed

Lines changed: 295 additions & 1 deletion

File tree

Doc/library/xml.etree.elementtree.rst

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,6 +1332,60 @@ QName Objects
13321332

13331333

13341334

1335+
.. _elementtree-xmlpulltarget-objects:
1336+
1337+
XMLPullTarget Objects
1338+
^^^^^^^^^^^^^^^^^^^^^
1339+
1340+
1341+
.. class:: XMLPullTarget(element_factory=None)
1342+
1343+
A target of :class:`XMLParser` which reports elements
1344+
without building a tree.
1345+
It can be used with :class:`XMLPullParser` and :func:`iterparse`,
1346+
which report the objects returned by its methods.
1347+
The reported object is an :class:`Element` with the tag, the attributes
1348+
and, for the ``"end"`` event, the text, but without children:
1349+
they are reported as their own events.
1350+
Nothing is kept, so a document of any size can be parsed
1351+
with a constant amount of memory.
1352+
1353+
*element_factory*, when given, is called to create new elements,
1354+
like the argument of :class:`TreeBuilder` with the same name.
1355+
1356+
Use :meth:`XMLPullParser.expand` to build the subtree of an element.
1357+
1358+
.. method:: start(tag, attrib)
1359+
1360+
Opens a new element and returns it, without children and text.
1361+
1362+
.. method:: data(data)
1363+
1364+
Adds text to the current element,
1365+
or to the tail of the last closed one.
1366+
1367+
.. method:: end(tag)
1368+
1369+
Closes the current element and returns the same object
1370+
which was returned by :meth:`start`, with its text,
1371+
but still without children.
1372+
1373+
.. method:: comment(text)
1374+
1375+
Handles a comment and returns a comment element.
1376+
1377+
.. method:: pi(target, text=None)
1378+
1379+
Handles a processing instruction
1380+
and returns a processing instruction element.
1381+
1382+
.. method:: close()
1383+
1384+
Returns ``None``.
1385+
1386+
.. versionadded:: next
1387+
1388+
13351389
.. _elementtree-treebuilder-objects:
13361390

13371391
TreeBuilder Objects
@@ -1588,6 +1642,23 @@ XMLPullParser Objects
15881642
Any events not yet retrieved when the parser is closed can still be
15891643
read with :meth:`read_events`.
15901644

1645+
.. method:: expand(element)
1646+
1647+
Build the subtree of *element* from the events which are already read
1648+
from the parser, and return *element*.
1649+
The events of its descendants are consumed and the corresponding
1650+
objects are added to it, up to the ``"end"`` event of *element*.
1651+
1652+
Both the ``"start"`` and the ``"end"`` events should be reported,
1653+
and the ``"start"`` event of *element* should be already read.
1654+
:exc:`ValueError` is raised if the element is not complete yet;
1655+
feed more data to the parser and call :meth:`!expand` again.
1656+
The iterator returned by :func:`iterparse` has a method with the same
1657+
name which reads more data itself.
1658+
1659+
.. versionadded:: next
1660+
1661+
15911662
.. method:: read_events()
15921663

15931664
Return an iterator over the events which have been encountered in the

Doc/whatsnew/3.16.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,14 @@ xml
715715
building a tree for it.
716716
(Contributed by Serhiy Storchaka in :gh:`63102`.)
717717

718+
* Add :class:`~xml.etree.ElementTree.XMLPullTarget`, a parser target which
719+
reports elements without children, and
720+
:meth:`~xml.etree.ElementTree.XMLPullParser.expand`, which builds the
721+
subtree of a reported element.
722+
They allow to process a document of any size with a constant amount of
723+
memory, like :mod:`xml.dom.pulldom`, but much faster.
724+
(Contributed by Serhiy Storchaka in :gh:`63102`.)
725+
718726
zipfile
719727
-------
720728

Lib/test/test_xml_etree.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1681,6 +1681,42 @@ def test_target(self):
16811681
])
16821682
self.assertIsNone(it.root)
16831683

1684+
def test_pull_target(self):
1685+
with open(SIMPLE_XMLFILE, 'rb') as f:
1686+
it = ET.iterparse(f, events=('start', 'end'),
1687+
target=ET.XMLPullTarget())
1688+
self.assertEqual([(action, elem.tag) for action, elem in it], [
1689+
('start', 'root'),
1690+
('start', 'element'),
1691+
('end', 'element'),
1692+
('start', 'element'),
1693+
('end', 'element'),
1694+
('start', 'empty-element'),
1695+
('end', 'empty-element'),
1696+
('end', 'root'),
1697+
])
1698+
self.assertIsNone(it.root)
1699+
1700+
def test_pull_target_expand(self):
1701+
with open(SIMPLE_XMLFILE, 'rb') as f:
1702+
it = ET.iterparse(f, events=('start', 'end'),
1703+
target=ET.XMLPullTarget())
1704+
action, root = next(it)
1705+
self.assertEqual((action, root.tag), ('start', 'root'))
1706+
action, elem = next(it)
1707+
self.assertEqual((action, elem.tag), ('start', 'element'))
1708+
self.assertIs(it.expand(elem), elem)
1709+
self.assertEqual(ET.tostring(elem).rstrip(),
1710+
b'<element key="value">text</element>')
1711+
# the events of the subtree are consumed
1712+
self.assertEqual([(action, elem.tag) for action, elem in it], [
1713+
('start', 'element'),
1714+
('end', 'element'),
1715+
('start', 'empty-element'),
1716+
('end', 'empty-element'),
1717+
('end', 'root'),
1718+
])
1719+
16841720
def test_parser_with_target(self):
16851721
with open(SIMPLE_XMLFILE, 'rb') as f:
16861722
parser = ET.XMLParser(target=self.Target())
@@ -1879,6 +1915,59 @@ def assert_event_tuples(self, parser, expected, max_events=None):
18791915
list(islice(parser.read_events(), max_events)),
18801916
expected)
18811917

1918+
def test_pull_target(self):
1919+
# the target reports elements without children
1920+
parser = ET.XMLPullParser(events=('start', 'end', 'comment', 'pi'),
1921+
target=ET.XMLPullTarget())
1922+
self._feed(parser,
1923+
"<root a='1'><!-- c --><?pitarget data?><a>t<b/></a></root>")
1924+
events = list(parser.read_events())
1925+
self.assertEqual([(action, getattr(obj.tag, '__name__', obj.tag))
1926+
for action, obj in events],
1927+
[('start', 'root'), ('comment', 'Comment'),
1928+
('pi', 'ProcessingInstruction'),
1929+
('start', 'a'), ('start', 'b'), ('end', 'b'),
1930+
('end', 'a'), ('end', 'root')])
1931+
root = events[0][1]
1932+
self.assertEqual(root.attrib, {'a': '1'})
1933+
self.assertEqual(len(root), 0)
1934+
self.assertEqual(events[1][1].text, ' c ')
1935+
self.assertEqual(events[2][1].text, 'pitarget data')
1936+
a_start, a_end = events[3][1], events[-2][1]
1937+
self.assertIs(a_start, a_end)
1938+
self.assertEqual(a_end.text, 't')
1939+
self.assertEqual(len(a_end), 0)
1940+
self.assertIsNone(parser.close())
1941+
1942+
def test_pull_target_expand(self):
1943+
parser = ET.XMLPullParser(events=('start', 'end'),
1944+
target=ET.XMLPullTarget())
1945+
self._feed(parser, "<root><a x='1'>t<b>deep</b></a><c/></root>")
1946+
parser.close()
1947+
events = parser.read_events()
1948+
action, root = next(events)
1949+
self.assertEqual((action, root.tag), ('start', 'root'))
1950+
action, a = next(events)
1951+
self.assertEqual((action, a.tag), ('start', 'a'))
1952+
self.assertIs(parser.expand(a), a)
1953+
self.assertEqual(ET.tostring(a), b'<a x="1">t<b>deep</b></a>')
1954+
# the events of the subtree are consumed, the rest is intact
1955+
self.assertEqual([(action, obj.tag) for action, obj in events],
1956+
[('start', 'c'), ('end', 'c'), ('end', 'root')])
1957+
1958+
def test_pull_target_expand_incomplete(self):
1959+
parser = ET.XMLPullParser(events=('start', 'end'),
1960+
target=ET.XMLPullTarget())
1961+
self._feed(parser, "<root><a>t")
1962+
events = parser.read_events()
1963+
next(events)
1964+
action, a = next(events)
1965+
with self.assertRaises(ValueError):
1966+
parser.expand(a)
1967+
self._feed(parser, "</a></root>")
1968+
self.assertIs(parser.expand(a), a)
1969+
self.assertEqual(ET.tostring(a), b'<a>t</a>')
1970+
18821971
def assert_event_tags(self, parser, expected, max_events=None):
18831972
events = islice(parser.read_events(), max_events)
18841973
self.assertEqual([(action, elem.tag) for action, elem in events],

Lib/xml/etree/ElementTree.py

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@
8484
"tostring", "tostringlist",
8585
"TreeBuilder",
8686
"XML", "XMLID",
87-
"XMLParser", "XMLPullParser",
87+
"XMLParser", "XMLPullParser", "XMLPullTarget",
8888
"register_namespace",
8989
"canonicalize", "C14NWriterTarget",
9090
]
@@ -1287,6 +1287,17 @@ def iterator(source):
12871287
class IterParseIterator(collections.abc.Iterator):
12881288
__next__ = gen.__next__
12891289

1290+
def expand(self, element):
1291+
"""Build the subtree of *element*, reading more data if needed."""
1292+
while True:
1293+
try:
1294+
return pullparser.expand(element)
1295+
except ValueError:
1296+
data = source.read(16 * 1024)
1297+
if not data:
1298+
raise
1299+
pullparser.feed(data)
1300+
12901301
def close(self):
12911302
nonlocal close_source
12921303
if close_source:
@@ -1365,6 +1376,39 @@ def read_events(self):
13651376
else:
13661377
yield event
13671378

1379+
def expand(self, element):
1380+
"""Build the subtree of *element* from the queued events.
1381+
1382+
The events of the descendants of *element* are consumed and the
1383+
corresponding objects are added to it, up to the "end" event of
1384+
*element* itself. Returns *element*.
1385+
1386+
Both the "start" and the "end" events should be reported, and
1387+
the "start" event of *element* should be already read.
1388+
Raise ValueError if the element is not complete yet: feed more data
1389+
and call expand() again.
1390+
"""
1391+
events = self._events_queue
1392+
stack = [element]
1393+
n = 0
1394+
for event in events:
1395+
n += 1
1396+
if isinstance(event, Exception):
1397+
raise event
1398+
kind, obj = event
1399+
if kind == 'start':
1400+
stack[-1].append(obj)
1401+
stack.append(obj)
1402+
elif kind == 'end':
1403+
if obj is element:
1404+
for _ in range(n):
1405+
events.popleft()
1406+
return element
1407+
stack.pop()
1408+
elif kind in ('comment', 'pi'):
1409+
stack[-1].append(obj)
1410+
raise ValueError("the element is not complete yet")
1411+
13681412
def flush(self):
13691413
if self._parser is None:
13701414
raise ValueError("flush() called after end of stream")
@@ -1551,6 +1595,82 @@ def _handle_single(self, factory, insert, *args):
15511595
return elem
15521596

15531597

1598+
class XMLPullTarget:
1599+
"""Parser target which reports elements without building a tree.
1600+
1601+
It can be used with XMLPullParser and iterparse(). The reported object
1602+
is an Element with the tag, the attributes and, for the "end" event, the
1603+
text, but without children: they are reported as their own events.
1604+
Nothing is kept, so a document of any size can be parsed with a constant
1605+
amount of memory.
1606+
1607+
Use XMLPullParser.expand() to build the subtree of an element.
1608+
1609+
"""
1610+
def __init__(self, element_factory=None):
1611+
if element_factory is None:
1612+
element_factory = Element
1613+
self._factory = element_factory
1614+
self._stack = []
1615+
self._last = None
1616+
self._tail = False
1617+
1618+
def start(self, tag, attrib):
1619+
"""Open a new element.
1620+
1621+
Returns the new Element, without children and text.
1622+
"""
1623+
elem = self._factory(tag, attrib)
1624+
self._stack.append(elem)
1625+
self._last = elem
1626+
self._tail = False
1627+
return elem
1628+
1629+
def data(self, data):
1630+
"""Add text to the current element or to the last closed one."""
1631+
last = self._last
1632+
if last is None:
1633+
return
1634+
if self._tail:
1635+
last.tail = last.tail + data if last.tail else data
1636+
else:
1637+
last.text = last.text + data if last.text else data
1638+
1639+
def end(self, tag):
1640+
"""Close the current element.
1641+
1642+
Returns the same Element which was returned by start(), with its
1643+
text, but still without children.
1644+
"""
1645+
elem = self._stack.pop()
1646+
self._last = elem
1647+
self._tail = True
1648+
return elem
1649+
1650+
def comment(self, text):
1651+
"""Handle a comment. Returns a comment element."""
1652+
elem = Comment(text)
1653+
self._last = elem
1654+
self._tail = True
1655+
return elem
1656+
1657+
def pi(self, target, text=None):
1658+
"""Handle a processing instruction.
1659+
1660+
Returns a processing instruction element.
1661+
"""
1662+
elem = ProcessingInstruction(target, text)
1663+
self._last = elem
1664+
self._tail = True
1665+
return elem
1666+
1667+
def close(self):
1668+
"""Flush the buffers and return None."""
1669+
self._stack.clear()
1670+
self._last = None
1671+
return None
1672+
1673+
15541674
# also see ElementTree and TreeBuilder
15551675
class XMLParser:
15561676
"""Element structure builder for XML source data based on the expat parser.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Add :class:`xml.etree.ElementTree.XMLPullTarget`, a parser target which
2+
reports elements without building a tree, and
3+
:meth:`~xml.etree.ElementTree.XMLPullParser.expand`, which builds the subtree
4+
of a reported element from the events which follow it. Together they allow
5+
processing a document of any size with a constant amount of memory, like
6+
:mod:`xml.dom.pulldom`, but much faster.

0 commit comments

Comments
 (0)