Skip to content

Commit 7cb3a24

Browse files
pythongh-81055: Support CDATA sections in xml.etree.ElementTree
CDATA is a new factory, like Comment and ProcessingInstruction, which creates a special element serialized as a CDATA section. Its content is character data: it is not escaped, "]]>" in it is split between two sections, and it is returned by itertext() and by the "text" serialization method. TreeBuilder gets the cdata_factory and insert_cdata arguments. When insert_cdata is set, a CDATA section in the input is kept as such instead of being parsed as text, so that the document can be written back unchanged. Expat reports the content of a CDATA section as ordinary character data, but it reports the boundaries, so the builder gets the start_cdata() and end_cdata() methods, and XMLParser calls them like comment() and pi(). _set_factories() takes the third factory, which the C implementation needs for itertext(), and the pyexpat capsule gets SetCdataSectionHandler.
1 parent d636e3c commit 7cb3a24

9 files changed

Lines changed: 586 additions & 36 deletions

File tree

Doc/library/xml.etree.elementtree.rst

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,28 @@ Functions
548548
.. versionadded:: 3.8
549549

550550

551+
.. function:: CDATA(text=None)
552+
553+
CDATA section factory.
554+
This factory function creates a special element
555+
which the standard serializer serializes as a CDATA section.
556+
*text* is a string containing the content of the CDATA section.
557+
558+
The content of a CDATA section is character data:
559+
it is not escaped when serialized, and it is returned
560+
by :meth:`Element.itertext` and by the ``"text"`` serialization method.
561+
``"]]>"`` cannot occur in a CDATA section,
562+
so the content which contains it is split into several sections.
563+
564+
Note that a CDATA section in the input is parsed as text by default:
565+
its content is added to the tree as ordinary character data.
566+
A tree only contains CDATA sections if they have been inserted into it
567+
using one of the :class:`Element` methods,
568+
or if the parser target collects them; see :class:`TreeBuilder`.
569+
570+
.. versionadded:: next
571+
572+
551573
.. function:: Comment(text=None)
552574

553575
Comment element factory. This factory function creates a special element
@@ -1051,9 +1073,14 @@ Element Objects
10511073

10521074
Creates a text iterator. The iterator loops over this element and all
10531075
subelements, in document order, and returns all inner text.
1076+
The content of CDATA sections is a part of the inner text,
1077+
but the content of comments and processing instructions is not.
10541078

10551079
.. versionadded:: 3.2
10561080

1081+
.. versionchanged:: next
1082+
The content of CDATA sections is returned.
1083+
10571084

10581085
.. method:: makeelement(tag, attrib)
10591086

@@ -1270,7 +1297,9 @@ TreeBuilder Objects
12701297

12711298

12721299
.. class:: TreeBuilder(element_factory=None, *, comment_factory=None, \
1273-
pi_factory=None, insert_comments=False, insert_pis=False)
1300+
pi_factory=None, cdata_factory=None, \
1301+
insert_comments=False, insert_pis=False, \
1302+
insert_cdata=False)
12741303

12751304
Generic element structure builder. This builder converts a sequence of
12761305
start, data, end, comment and pi method calls to a well-formed element
@@ -1288,6 +1317,16 @@ TreeBuilder Objects
12881317
comments/pis will be inserted into the tree if they appear within the root
12891318
element (but not outside of it).
12901319

1320+
The *cdata_factory* function, when given,
1321+
should behave like the :func:`CDATA` function.
1322+
If *insert_cdata* is true, a CDATA section in the input is created
1323+
with this factory and inserted into the tree;
1324+
otherwise its content is added to the tree as ordinary character data
1325+
and the factory is not called.
1326+
1327+
.. versionchanged:: next
1328+
Added the *cdata_factory* and *insert_cdata* arguments.
1329+
12911330
.. method:: close()
12921331

12931332
Flushes the builder buffers, and returns the toplevel document
@@ -1328,6 +1367,26 @@ TreeBuilder Objects
13281367
.. versionadded:: 3.8
13291368

13301369

1370+
.. method:: start_cdata()
1371+
1372+
Begins a CDATA section.
1373+
The text added by :meth:`data` until the matching :meth:`end_cdata`
1374+
call is the content of the section.
1375+
1376+
.. versionadded:: next
1377+
1378+
1379+
.. method:: end_cdata()
1380+
1381+
Ends a CDATA section.
1382+
If ``insert_cdata`` is true, creates a CDATA section
1383+
with the collected content and adds it to the tree,
1384+
and returns it. Otherwise returns ``None``
1385+
and the collected content is left as ordinary character data.
1386+
1387+
.. versionadded:: next
1388+
1389+
13311390
In addition, a custom :class:`TreeBuilder` object can provide the
13321391
following methods:
13331392

Doc/whatsnew/3.16.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,22 @@ xml
648648
rather than defaulted from the DTD.
649649
(Contributed by Jason Orendorff and Serhiy Storchaka in :gh:`44871`.)
650650

651+
* Add :func:`~xml.etree.ElementTree.CDATA` to :mod:`xml.etree.ElementTree`,
652+
a factory of special elements which are serialized as CDATA sections,
653+
like :func:`~xml.etree.ElementTree.Comment` and
654+
:func:`~xml.etree.ElementTree.ProcessingInstruction`.
655+
The content of a CDATA section is character data:
656+
it is not escaped when serialized, and it is returned by
657+
:meth:`~xml.etree.ElementTree.Element.itertext`
658+
and by the ``"text"`` serialization method.
659+
:class:`~xml.etree.ElementTree.TreeBuilder` supports the *cdata_factory*
660+
and *insert_cdata* arguments, which make the parser keep CDATA sections
661+
instead of parsing them as text, and the new :meth:`!start_cdata` and
662+
:meth:`!end_cdata` methods, which :class:`~xml.etree.ElementTree.XMLParser`
663+
calls for the boundaries of a CDATA section, like :meth:`!comment` and
664+
:meth:`!pi`.
665+
(Contributed by Serhiy Storchaka in :gh:`81055`.)
666+
651667
zipfile
652668
-------
653669

Include/pyexpat.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ struct PyExpat_CAPI
6565
/* might be NULL for expat < 2.8.0 */
6666
XML_Bool (*SetHashSalt16Bytes)(
6767
XML_Parser parser, const uint8_t entropy[16]);
68+
void (*SetCdataSectionHandler)(
69+
XML_Parser parser, XML_StartCdataSectionHandler start,
70+
XML_EndCdataSectionHandler end);
6871
/* always add new stuff to the end! */
6972
};
7073

Lib/test/test_xml_etree.py

Lines changed: 141 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1310,6 +1310,35 @@ def test_comment_serialization(self):
13101310
# no comments in text serialization
13111311
self.assertEqual(ET.tostring(comm, method='text'), b'')
13121312

1313+
def test_cdata_serialization(self):
1314+
cdata = ET.CDATA('<spam> & ham')
1315+
# the content of a CDATA section is not escaped
1316+
self.assertEqual(ET.tostring(cdata), b'<![CDATA[<spam> & ham]]>')
1317+
self.assertEqual(ET.tostring(cdata, method='html'),
1318+
b'<![CDATA[<spam> & ham]]>')
1319+
# but it is character data
1320+
self.assertEqual(ET.tostring(cdata, method='text'), b'<spam> & ham')
1321+
# an empty CDATA section
1322+
self.assertEqual(ET.tostring(ET.CDATA()), b'<![CDATA[]]>')
1323+
self.assertEqual(ET.tostring(ET.CDATA('')), b'<![CDATA[]]>')
1324+
# "]]>" cannot occur in a CDATA section, it is split in two
1325+
self.assertEqual(ET.tostring(ET.CDATA('a]]>b')),
1326+
b'<![CDATA[a]]]]><![CDATA[>b]]>')
1327+
self.assertRaises(TypeError, ET.tostring, ET.CDATA(42))
1328+
1329+
def test_cdata_in_element(self):
1330+
elem = ET.XML('<root>before</root>')
1331+
cdata = ET.CDATA('<spam> & ham')
1332+
cdata.tail = 'after'
1333+
elem.append(cdata)
1334+
self.assertEqual(ET.tostring(elem),
1335+
b'<root>before<![CDATA[<spam> & ham]]>after</root>')
1336+
self.assertEqual(ET.tostring(elem, method='text'),
1337+
b'before<spam> & hamafter')
1338+
# the written form is parsed back to the same text
1339+
self.assertEqual(''.join(ET.fromstring(ET.tostring(elem)).itertext()),
1340+
'before<spam> & hamafter')
1341+
13131342
def test_processinginstruction_serialization(self):
13141343
# Test ProcessingInstruction directly
13151344

@@ -3771,6 +3800,21 @@ def test_processinginstruction(self):
37713800
self.assertEqual(''.join(pi.itertext()), '')
37723801
self.assertEqual(list(pi.iter()), [pi])
37733802

3803+
def test_cdata(self):
3804+
e = ET.Element('root')
3805+
e.text = 'before'
3806+
cdata = ET.CDATA('content')
3807+
self.assertEqual(cdata.text, 'content')
3808+
cdata.tail = 'after'
3809+
e.append(cdata)
3810+
# unlike a comment or a processing instruction,
3811+
# a CDATA section contains character data
3812+
self.assertEqual(''.join(e.itertext()), 'beforecontentafter')
3813+
self.assertEqual(list(e.iter()), [e, cdata])
3814+
self.assertEqual(list(e.iter('root')), [e])
3815+
self.assertEqual(''.join(cdata.itertext()), 'content')
3816+
self.assertEqual(list(cdata.iter()), [cdata])
3817+
37743818
def test_corners(self):
37753819
# single root, no subelements
37763820
a = ET.Element('a')
@@ -3904,6 +3948,101 @@ def test_treebuilder_pi(self):
39043948
self.assertEqual(b.pi('target'), (len('target'), None))
39053949
self.assertEqual(b.pi('pitarget', ' text '), (len('pitarget'), ' text '))
39063950

3951+
def test_treebuilder_cdata(self):
3952+
b = ET.TreeBuilder()
3953+
# nothing is created unless insert_cdata is true
3954+
self.assertIsNone(b.start_cdata())
3955+
self.assertIsNone(b.end_cdata())
3956+
3957+
b = ET.TreeBuilder(insert_cdata=True)
3958+
b.start('a', {})
3959+
b.data('before')
3960+
b.start_cdata()
3961+
b.data('a < b')
3962+
cdata = b.end_cdata()
3963+
self.assertEqual(cdata.tag, ET.CDATA)
3964+
self.assertEqual(cdata.text, 'a < b')
3965+
b.data('after')
3966+
b.end('a')
3967+
a = b.close()
3968+
self.assertEqual(ET.tostring(a),
3969+
b'<a>before<![CDATA[a < b]]>after</a>')
3970+
3971+
def test_treebuilder_cdata_factory(self):
3972+
# the factory is only called if insert_cdata is true
3973+
b = ET.TreeBuilder(cdata_factory=len)
3974+
b.start_cdata()
3975+
self.assertIsNone(b.end_cdata())
3976+
3977+
b = ET.TreeBuilder(insert_cdata=True,
3978+
cdata_factory=lambda text: ET.Comment('was: ' + text))
3979+
b.start('a', {})
3980+
b.start_cdata()
3981+
b.data('abc')
3982+
self.assertEqual(b.end_cdata().tag, ET.Comment)
3983+
b.end('a')
3984+
self.assertEqual(ET.tostring(b.close()), b'<a><!--was: abc--></a>')
3985+
3986+
def test_parse_cdata(self):
3987+
xml = '<a>before<![CDATA[a < b]]>after<b><![CDATA[deep]]></b></a>'
3988+
# by default the content of a CDATA section is ordinary text
3989+
a = ET.fromstring(xml)
3990+
self.assertEqual(ET.tostring(a),
3991+
b'<a>beforea &lt; bafter<b>deep</b></a>')
3992+
3993+
parser = ET.XMLParser(target=ET.TreeBuilder(insert_cdata=True))
3994+
parser.feed(xml)
3995+
a = parser.close()
3996+
self.assertEqual(summarize_list(a), [ET.CDATA, 'b'])
3997+
self.assertEqual(a.text, 'before')
3998+
self.assertEqual(a[0].text, 'a < b')
3999+
self.assertEqual(a[0].tail, 'after')
4000+
# the tree is serialized back to the source
4001+
self.assertEqual(ET.tostring(a, encoding='unicode'), xml)
4002+
4003+
def test_parse_empty_cdata(self):
4004+
parser = ET.XMLParser(target=ET.TreeBuilder(insert_cdata=True))
4005+
parser.feed('<a><![CDATA[]]></a>')
4006+
a = parser.close()
4007+
self.assertEqual(summarize_list(a), [ET.CDATA])
4008+
self.assertEqual(a[0].text, '')
4009+
4010+
def test_parse_cdata_subclass(self):
4011+
class TreeBuilderSubclass(ET.TreeBuilder):
4012+
pass
4013+
4014+
xml = '<a>text<![CDATA[a < b]]>tail</a>'
4015+
parser = ET.XMLParser(target=TreeBuilderSubclass(insert_cdata=True))
4016+
parser.feed(xml)
4017+
a = parser.close()
4018+
self.assertEqual(a.text, 'text')
4019+
self.assertEqual(a[0].text, 'a < b')
4020+
self.assertEqual(a[0].tail, 'tail')
4021+
4022+
def test_parse_cdata_custom_target(self):
4023+
events = []
4024+
class Target:
4025+
def start(self, tag, attrib):
4026+
events.append(('start', tag))
4027+
def end(self, tag):
4028+
events.append(('end', tag))
4029+
def data(self, data):
4030+
events.append(('data', data))
4031+
def start_cdata(self):
4032+
events.append(('start_cdata',))
4033+
def end_cdata(self):
4034+
events.append(('end_cdata',))
4035+
def close(self):
4036+
return events
4037+
4038+
parser = ET.XMLParser(target=Target())
4039+
parser.feed('<a>text<![CDATA[a < b]]>tail</a>')
4040+
self.assertEqual(parser.close(), [
4041+
('start', 'a'), ('data', 'text'),
4042+
('start_cdata',), ('data', 'a < b'), ('end_cdata',),
4043+
('data', 'tail'), ('end', 'a'),
4044+
])
4045+
39074046
def test_late_tail(self):
39084047
# Issue #37399: The tail of an ignored comment could overwrite the text before it.
39094048
class TreeBuilderSubclass(ET.TreeBuilder):
@@ -4981,9 +5120,9 @@ def cleanup():
49815120
unittest.addModuleCleanup(setattr, ElementPath, "_cache", path_cache)
49825121
ElementPath._cache = path_cache.copy()
49835122

4984-
# Align the Comment/PI factories.
5123+
# Align the Comment/PI/CDATA factories.
49855124
if hasattr(ET, '_set_factories'):
4986-
old_factories = ET._set_factories(ET.Comment, ET.PI)
5125+
old_factories = ET._set_factories(ET.Comment, ET.PI, ET.CDATA)
49875126
unittest.addModuleCleanup(ET._set_factories, *old_factories)
49885127

49895128

0 commit comments

Comments
 (0)