Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion Doc/library/xml.etree.elementtree.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1277,6 +1277,28 @@ QName Objects
^^^^^^^^^^^^^


.. class:: CDATA(text)

A subclass of :class:`str` for character data
which is serialized as a CDATA section.
It can be used as the text or the tail of an element::

elem.text = CDATA('<raw> & unescaped')

The content of a CDATA section is character data:
it is not escaped when serialized, and it is a part of the inner text
returned by :meth:`Element.itertext` and by the ``"text"``
serialization method.
``"]]>"`` cannot occur in a CDATA section,
so a content which contains it is split into several sections.

Note that CDATA sections in the input are parsed as ordinary character
data by default, and a :class:`CDATA` string is only created if the
parser target preserves them; see :class:`TreeBuilder`.

.. versionadded:: next


.. class:: QName(text_or_uri, tag=None)

QName wrapper. This can be used to wrap a QName attribute value, in order
Expand All @@ -1295,7 +1317,8 @@ TreeBuilder Objects


.. class:: TreeBuilder(element_factory=None, *, comment_factory=None, \
pi_factory=None, insert_comments=False, insert_pis=False)
pi_factory=None, insert_comments=False, \
insert_pis=False, insert_cdata=False)

Generic element structure builder. This builder converts a sequence of
start, data, end, comment and pi method calls to a well-formed element
Expand All @@ -1313,6 +1336,20 @@ TreeBuilder Objects
comments/pis will be inserted into the tree if they appear within the root
element (but not outside of it).

When *insert_cdata* is true, the content of a CDATA section is added to
the tree as a :class:`CDATA` string, so that the section is preserved
when the tree is serialized.
It becomes the text or the tail of an element if that is not set yet,
and otherwise a new element with the tag ``None`` is created to hold it.
A section does not share the place with what follows it: if character data
or another section follows it, it is moved to such an element, and the
character data becomes the tail of that element.
When *insert_cdata* is false (the default),
the content is added as ordinary character data.

.. versionchanged:: next
Added the *insert_cdata* argument.

.. method:: close()

Flushes the builder buffers, and returns the toplevel document
Expand All @@ -1330,6 +1367,27 @@ TreeBuilder Objects
closed element.


.. method:: start_cdata()

Begins a CDATA section.
The text added by :meth:`data` until the matching :meth:`end_cdata`
call is the content of the section.

.. versionadded:: next


.. method:: end_cdata()

Ends a CDATA section.
If *insert_cdata* is true, adds the collected content to the tree as a
:class:`CDATA` string, and returns the element created to hold it, or
``None`` if it was added as the text or the tail of an existing element.
If *insert_cdata* is false, returns ``None`` and the collected content
is left as ordinary character data.

.. versionadded:: next


.. method:: start(tag, attrs)

Opens a new element. *tag* is the element name. *attrs* is a dictionary
Expand Down
7 changes: 7 additions & 0 deletions Doc/whatsnew/3.16.rst
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,13 @@ xml
rather than defaulted from the DTD.
(Contributed by Jason Orendorff and Serhiy Storchaka in :gh:`44871`.)

* Add :class:`~xml.etree.ElementTree.CDATA` to :mod:`xml.etree.ElementTree`,
a :class:`str` subclass for character data which is serialized as a CDATA
section. :class:`~xml.etree.ElementTree.TreeBuilder` supports the
*insert_cdata* argument, which makes it preserve CDATA sections of the
parsed document.
(Contributed by Serhiy Storchaka in :gh:`81055`.)

zipfile
-------

Expand Down
1 change: 1 addition & 0 deletions Include/internal/pycore_global_objects_fini_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Include/internal/pycore_global_strings.h
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,7 @@ struct _Py_global_strings {
STRUCT_FOR_ID(initval)
STRUCT_FOR_ID(inner_size)
STRUCT_FOR_ID(input)
STRUCT_FOR_ID(insert_cdata)
STRUCT_FOR_ID(insert_comments)
STRUCT_FOR_ID(insert_pis)
STRUCT_FOR_ID(instructions)
Expand Down
1 change: 1 addition & 0 deletions Include/internal/pycore_runtime_init_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Include/internal/pycore_unicodeobject_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Include/pyexpat.h
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ struct PyExpat_CAPI
/* might be NULL for expat < 2.8.0 */
XML_Bool (*SetHashSalt16Bytes)(
XML_Parser parser, const uint8_t entropy[16]);
void (*SetCdataSectionHandler)(
XML_Parser parser, XML_StartCdataSectionHandler start,
XML_EndCdataSectionHandler end);
/* always add new stuff to the end! */
};

223 changes: 221 additions & 2 deletions Lib/test/test_xml_etree.py
Original file line number Diff line number Diff line change
Expand Up @@ -1464,6 +1464,55 @@ def test_comment_serialization(self):
# no comments in text serialization
self.assertEqual(ET.tostring(comm, method='text'), b'')

def test_cdata_serialization(self):
elem = ET.Element('root')
elem.text = ET.CDATA('<spam> & ham')
# the content of a CDATA section is not escaped
self.assertEqual(ET.tostring(elem),
b'<root><![CDATA[<spam> & ham]]></root>')
# but it is character data
self.assertEqual(ET.tostring(elem, method='text'), b'<spam> & ham')
# an empty CDATA section is preserved
elem.text = ET.CDATA('')
self.assertEqual(ET.tostring(elem), b'<root><![CDATA[]]></root>')
empty = ET.Element('root')
sub = ET.SubElement(empty, 'sub')
sub.tail = ET.CDATA('')
self.assertEqual(ET.tostring(empty),
b'<root><sub /><![CDATA[]]></root>')
# "]]>" cannot occur in a CDATA section, it is split in two
elem.text = ET.CDATA('a]]>b')
self.assertEqual(ET.tostring(elem),
b'<root><![CDATA[a]]]]><![CDATA[>b]]></root>')

def test_cdata_as_tail(self):
elem = ET.XML('<root>before<b /></root>')
elem[0].tail = ET.CDATA('<spam> & ham')
self.assertEqual(ET.tostring(elem),
b'<root>before<b /><![CDATA[<spam> & ham]]></root>')
self.assertEqual(ET.tostring(elem, method='text'),
b'before<spam> & ham')
# the written form is parsed back to the same text
self.assertEqual(''.join(ET.fromstring(ET.tostring(elem)).itertext()),
'before<spam> & ham')

def test_cdata_is_str(self):
cdata = ET.CDATA('text')
self.assertIsInstance(cdata, str)
self.assertEqual(cdata, 'text')
# an ordinary string is not serialized as a CDATA section
elem = ET.Element('root')
elem.text = str(cdata)
self.assertEqual(ET.tostring(elem), b'<root>text</root>')

def test_cdata_subclass(self):
class MyCDATA(ET.CDATA):
pass

elem = ET.Element('root')
elem.text = MyCDATA('<spam>')
self.assertEqual(ET.tostring(elem), b'<root><![CDATA[<spam>]]></root>')

def test_processinginstruction_serialization(self):
# Test ProcessingInstruction directly

Expand Down Expand Up @@ -4058,6 +4107,176 @@ def test_treebuilder_pi(self):
self.assertEqual(b.pi('target'), (len('target'), None))
self.assertEqual(b.pi('pitarget', ' text '), (len('pitarget'), ' text '))

def test_treebuilder_cdata(self):
b = ET.TreeBuilder()
# nothing is created unless insert_cdata is true
self.assertIsNone(b.start_cdata())
self.assertIsNone(b.end_cdata())

b = ET.TreeBuilder(insert_cdata=True)
b.start('a', {})
b.data('before')
b.start_cdata()
b.data('a < b')
elem = b.end_cdata()
self.assertIsNone(elem.tag)
self.assertEqual(elem.text, 'a < b')
self.assertIsInstance(elem.text, ET.CDATA)
b.data('after')
b.end('a')
a = b.close()
self.assertEqual(ET.tostring(a),
b'<a>before<![CDATA[a < b]]>after</a>')

def test_parse_cdata_alternating(self):
parser = ET.XMLParser(target=ET.TreeBuilder(insert_cdata=True))
xml = '<a><![CDATA[x]]>t1<![CDATA[y]]>t2</a>'
parser.feed(xml)
a = parser.close()
# each section is the text of its element, the text which follows
# it is the tail
self.assertEqual(summarize_list(a), [None, None])
self.assertIsNone(a.text)
self.assertEqual(a[0].text, 'x')
self.assertIsInstance(a[0].text, ET.CDATA)
self.assertEqual(a[0].tail, 't1')
self.assertEqual(a[1].text, 'y')
self.assertIsInstance(a[1].text, ET.CDATA)
self.assertEqual(a[1].tail, 't2')
self.assertEqual(ET.tostring(a, encoding='unicode'), xml)

def test_parse_cdata(self):
xml = '<a>before<![CDATA[a < b]]>after<b><![CDATA[deep]]></b></a>'
# by default the content of a CDATA section is ordinary text
a = ET.fromstring(xml)
self.assertEqual(ET.tostring(a),
b'<a>beforea &lt; bafter<b>deep</b></a>')

parser = ET.XMLParser(target=ET.TreeBuilder(insert_cdata=True))
parser.feed(xml)
a = parser.close()
self.assertEqual(summarize_list(a), [None, 'b'])
self.assertEqual(a.text, 'before')
self.assertEqual(a[0].text, 'a < b')
self.assertIsInstance(a[0].text, ET.CDATA)
self.assertEqual(a[0].tail, 'after')
# no element is needed for the section which is the whole content
self.assertEqual(summarize_list(a[1]), [])
self.assertEqual(a[1].text, 'deep')
self.assertIsInstance(a[1].text, ET.CDATA)
# the content of a CDATA section is a part of the inner text
self.assertEqual(''.join(a.itertext()), 'beforea < bafterdeep')
# the tree is serialized back to the source
self.assertEqual(ET.tostring(a, encoding='unicode'), xml)

def test_parse_empty_cdata(self):
parser = ET.XMLParser(target=ET.TreeBuilder(insert_cdata=True))
parser.feed('<a><![CDATA[]]></a>')
a = parser.close()
# no element is needed, the content is the text of the parent
self.assertEqual(summarize_list(a), [])
self.assertEqual(a.text, '')
self.assertIsInstance(a.text, ET.CDATA)
# an empty section is written back
self.assertEqual(ET.tostring(a), b'<a><![CDATA[]]></a>')

def test_parse_cdata_merged(self):
# No element is created if the content can be the text of the parent
# or the tail of the preceding sibling.
def parse(xml):
parser = ET.XMLParser(target=ET.TreeBuilder(insert_cdata=True))
parser.feed(xml)
return parser.close()

a = parse('<a><![CDATA[x]]></a>')
self.assertEqual(summarize_list(a), [])
self.assertEqual(a.text, 'x')
self.assertIsInstance(a.text, ET.CDATA)

a = parse('<a><b /><![CDATA[x]]></a>')
self.assertEqual(summarize_list(a), ['b'])
self.assertEqual(a[0].tail, 'x')
self.assertIsInstance(a[0].tail, ET.CDATA)

# a section does not share the place with what follows it
a = parse('<a><![CDATA[x]]><![CDATA[y]]></a>')
self.assertEqual(summarize_list(a), [None, None])
self.assertIsNone(a.text)
self.assertEqual(a[0].text, 'x')
self.assertEqual(a[1].text, 'y')

a = parse('<a><b /><![CDATA[x]]><![CDATA[y]]></a>')
self.assertEqual(summarize_list(a), ['b', None, None])
self.assertIsNone(a[0].tail)
self.assertEqual(a[1].text, 'x')
self.assertEqual(a[2].text, 'y')

# the element is needed if the preceding text is not empty
a = parse('<a>text<![CDATA[x]]></a>')
self.assertEqual(summarize_list(a), [None])
a = parse('<a><b />t<![CDATA[x]]></a>')
self.assertEqual(summarize_list(a), ['b', None])
# if text follows the section, it is moved to an element
# and the text becomes its tail
a = parse('<a><![CDATA[x]]>tail</a>')
self.assertEqual(summarize_list(a), [None])
self.assertIsNone(a.text)
self.assertEqual(a[0].text, 'x')
self.assertIsInstance(a[0].text, ET.CDATA)
self.assertEqual(a[0].tail, 'tail')

@support.subTests('xml', (
'<a><![CDATA[x]]></a>',
'<a><![CDATA[x]]>tail</a>',
'<a>text<![CDATA[x]]></a>',
'<a><b /><![CDATA[x]]></a>',
'<a><b />t<![CDATA[x]]></a>',
'<a><![CDATA[x]]><![CDATA[y]]></a>',
'<a><b /><![CDATA[x]]><![CDATA[y]]></a>',
'<a><![CDATA[x]]><b /></a>',
'<a><b><![CDATA[x]]></b><![CDATA[y]]></a>',
))
def test_parse_cdata_roundtrip(self, xml):
parser = ET.XMLParser(target=ET.TreeBuilder(insert_cdata=True))
parser.feed(xml)
self.assertEqual(ET.tostring(parser.close(), encoding='unicode'), xml)

def test_parse_cdata_subclass(self):
class TreeBuilderSubclass(ET.TreeBuilder):
pass

xml = '<a>text<![CDATA[a < b]]>tail</a>'
parser = ET.XMLParser(target=TreeBuilderSubclass(insert_cdata=True))
parser.feed(xml)
a = parser.close()
self.assertEqual(a.text, 'text')
self.assertEqual(a[0].text, 'a < b')
self.assertEqual(a[0].tail, 'tail')

def test_parse_cdata_custom_target(self):
events = []
class Target:
def start(self, tag, attrib):
events.append(('start', tag))
def end(self, tag):
events.append(('end', tag))
def data(self, data):
events.append(('data', data))
def start_cdata(self):
events.append(('start_cdata',))
def end_cdata(self):
events.append(('end_cdata',))
def close(self):
return events

parser = ET.XMLParser(target=Target())
parser.feed('<a>text<![CDATA[a < b]]>tail</a>')
self.assertEqual(parser.close(), [
('start', 'a'), ('data', 'text'),
('start_cdata',), ('data', 'a < b'), ('end_cdata',),
('data', 'tail'), ('end', 'a'),
])

def test_late_tail(self):
# Issue #37399: The tail of an ignored comment could overwrite the text before it.
class TreeBuilderSubclass(ET.TreeBuilder):
Expand Down Expand Up @@ -5140,9 +5359,9 @@ def cleanup():
unittest.addModuleCleanup(setattr, ElementPath, "_cache", path_cache)
ElementPath._cache = path_cache.copy()

# Align the Comment/PI factories.
# Align the Comment/PI factories and the CDATA type.
if hasattr(ET, '_set_factories'):
old_factories = ET._set_factories(ET.Comment, ET.PI)
old_factories = ET._set_factories(ET.Comment, ET.PI, ET.CDATA)
unittest.addModuleCleanup(ET._set_factories, *old_factories)


Expand Down
Loading
Loading