https://github.com/python/cpython/commit/33083601f50d50e71d0f96e0fa7cc0c41d15e218
commit: 33083601f50d50e71d0f96e0fa7cc0c41d15e218
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-09-13T18:09:47+03:00
summary:

gh-81623: Do not add whitespace to significant content when pretty-printing 
(GH-156660)

Pretty-printing added whitespace inside an element which contains text,
which changed its content.  Now xml.dom.minidom.Node.toprettyxml() and
xml.etree.ElementTree.indent() do not add whitespace inside an element
which is marked with xml:space="preserve" or which contains text.
toprettyxml() also takes into account the content model declared in
the DTD: only white space in element content is ignorable.

files:
A Misc/NEWS.d/next/Library/2026-08-30-18-00-00.gh-issue-81623.Vh2Kt6.rst
M Doc/library/xml.dom.minidom.rst
M Doc/library/xml.etree.elementtree.rst
M Doc/whatsnew/3.16.rst
M Lib/test/test_minidom.py
M Lib/test/test_xml_etree.py
M Lib/xml/dom/minidom.py
M Lib/xml/etree/ElementTree.py

diff --git a/Doc/library/xml.dom.minidom.rst b/Doc/library/xml.dom.minidom.rst
index efc81f31e36a5be..eb4984e2b53d929 100644
--- a/Doc/library/xml.dom.minidom.rst
+++ b/Doc/library/xml.dom.minidom.rst
@@ -187,6 +187,12 @@ module documentation.  This section lists the differences 
between the API and
 
    The *standalone* argument behaves exactly as in :meth:`writexml`.
 
+   No indentation is added inside an element
+   which is marked with ``xml:space="preserve"``,
+   which is declared in the DTD as not having element content,
+   or, in absence of such declaration, which contains text,
+   because this would change its content.
+
    .. versionchanged:: 3.8
       The :meth:`toprettyxml` method now preserves the attribute order 
specified
       by the user.
@@ -194,6 +200,10 @@ module documentation.  This section lists the differences 
between the API and
    .. versionchanged:: 3.9
       The *standalone* parameter was added.
 
+   .. versionchanged:: next
+      Whitespace is no longer added inside an element with mixed content
+      or marked with ``xml:space="preserve"``.
+
 .. _dom-example:
 
 DOM Example
diff --git a/Doc/library/xml.etree.elementtree.rst 
b/Doc/library/xml.etree.elementtree.rst
index 4f2497c8246be30..a61fb05bf99d873 100644
--- a/Doc/library/xml.etree.elementtree.rst
+++ b/Doc/library/xml.etree.elementtree.rst
@@ -631,8 +631,16 @@ Functions
    characters by default.  For indenting partial subtrees inside of an
    already indented tree, pass the initial indentation level as *level*.
 
+   No whitespace is added inside an element
+   which is marked with ``xml:space="preserve"``
+   or which contains text, because this would change its content.
+
    .. versionadded:: 3.9
 
+   .. versionchanged:: next
+      Whitespace is no longer added inside an element with mixed content
+      or marked with ``xml:space="preserve"``.
+
 
 .. function:: iselement(element)
 
diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst
index 2eccc8e35605596..71ad3558b3be68f 100644
--- a/Doc/whatsnew/3.16.rst
+++ b/Doc/whatsnew/3.16.rst
@@ -702,6 +702,14 @@ xml
   and :meth:`!Document.createEntityReference`.
   (Contributed by Jason Orendorff and Serhiy Storchaka in :gh:`44871`.)
 
+* :meth:`~xml.dom.minidom.Node.toprettyxml` in :mod:`xml.dom.minidom`
+  and :func:`~xml.etree.ElementTree.indent` in :mod:`xml.etree.ElementTree`
+  no longer add whitespace inside an element
+  which is marked with ``xml:space="preserve"`` or which contains text.
+  :meth:`!toprettyxml` also takes into account
+  the content model declared in the DTD.
+  (Contributed by Serhiy Storchaka in :gh:`81623`.)
+
 * Add :meth:`!GetSpecifiedAttributeCount` method
   to the :mod:`XML parser <xml.parsers.expat>` objects.
   It tells how many of the reported attributes were given in the start tag
@@ -947,6 +955,15 @@ that may require changes to your code.
   Attributes defaulted in the DTD are no longer omitted when parsing.
   (Contributed by Jason Orendorff and Serhiy Storchaka in :gh:`44871`.)
 
+* :meth:`~xml.dom.minidom.Node.toprettyxml` in :mod:`xml.dom.minidom`
+  and :func:`~xml.etree.ElementTree.indent` in :mod:`xml.etree.ElementTree`
+  no longer add whitespace inside an element
+  which is marked with ``xml:space="preserve"`` or which contains text,
+  because this changed the content of the element.
+  :meth:`!toprettyxml` also takes into account
+  the content model declared in the DTD.
+  (Contributed by Serhiy Storchaka in :gh:`81623`.)
+
 * On Windows, seeking a pipe now fails instead of silently appearing to
   succeed: :func:`os.lseek` and :meth:`~io.IOBase.seek` raise :exc:`OSError`,
   and :meth:`~io.IOBase.seekable` returns ``False``.  As a consequence,
diff --git a/Lib/test/test_minidom.py b/Lib/test/test_minidom.py
index 2a7c3293174a249..837a54a64379f74 100644
--- a/Lib/test/test_minidom.py
+++ b/Lib/test/test_minidom.py
@@ -617,32 +617,76 @@ def testAltNewline(self):
         self.assertEqual(domstr, str.replace("\n", "\r\n"))
 
     def test_toprettyxml_with_text_nodes(self):
-        # see issue #4147, text nodes are not indented
+        # see gh-48397 and gh-81623,
+        # the content of an element with text is not changed
         decl = '<?xml version="1.0" ?>\n'
         self.assertEqual(parseString('<B>A</B>').toprettyxml(),
                          decl + '<B>A</B>\n')
         self.assertEqual(parseString('<C>A<B>A</B></C>').toprettyxml(),
-                         decl + '<C>\n\tA\n\t<B>A</B>\n</C>\n')
+                         decl + '<C>A<B>A</B></C>\n')
         self.assertEqual(parseString('<C><B>A</B>A</C>').toprettyxml(),
-                         decl + '<C>\n\t<B>A</B>\n\tA\n</C>\n')
+                         decl + '<C><B>A</B>A</C>\n')
         self.assertEqual(parseString('<C><B>A</B><B>A</B></C>').toprettyxml(),
                          decl + '<C>\n\t<B>A</B>\n\t<B>A</B>\n</C>\n')
         self.assertEqual(parseString('<C><B>A</B>A<B>A</B></C>').toprettyxml(),
-                         decl + '<C>\n\t<B>A</B>\n\tA\n\t<B>A</B>\n</C>\n')
+                         decl + '<C><B>A</B>A<B>A</B></C>\n')
+        # toprettyxml treats whitespace between elements as insignificant
+        self.assertEqual(parseString('<C> <B>A</B> </C>').toprettyxml(),
+                         decl + '<C>\n\t \n\t<B>A</B>\n\t \n</C>\n')
 
     def test_toprettyxml_with_adjacent_text_nodes(self):
-        # see issue #4147, adjacent text nodes are indented normally
+        # see gh-81623, adjacent text nodes are not separated
         dom = Document()
         elem = dom.createElement('elem')
         elem.appendChild(dom.createTextNode('TEXT'))
         elem.appendChild(dom.createTextNode('TEXT'))
         dom.appendChild(elem)
         decl = '<?xml version="1.0" ?>\n'
-        self.assertEqual(dom.toprettyxml(),
-                         decl + '<elem>\n\tTEXT\n\tTEXT\n</elem>\n')
+        self.assertEqual(dom.toprettyxml(), decl + '<elem>TEXTTEXT</elem>\n')
+
+    def test_toprettyxml_preserve(self):
+        decl = '<?xml version="1.0" ?>\n'
+        # xml:space="preserve" applies to the whole subtree
+        self.assertEqual(
+            parseString('<C xml:space="preserve"><B>A</B><B>A</B></C>'
+                        ).toprettyxml(),
+            decl + '<C xml:space="preserve"><B>A</B><B>A</B></C>\n')
+        self.assertEqual(
+            parseString('<C xml:space="preserve"><B><D/></B></C>'
+                        ).toprettyxml(),
+            decl + '<C xml:space="preserve"><B><D/></B></C>\n')
+        # other values do not preserve whitespace
+        self.assertEqual(
+            parseString('<C xml:space="default"><B>A</B></C>').toprettyxml(),
+            decl + '<C xml:space="default">\n\t<B>A</B>\n</C>\n')
+
+    def test_toprettyxml_with_non_xml_whitespace(self):
+        # only " \t\r\n" are whitespace in XML (see XML 1.0, 2.3)
+        decl = '<?xml version="1.0" ?>\n'
+        self.assertEqual(parseString('<C>\xa0<B>A</B></C>').toprettyxml(),
+                         decl + '<C>\xa0<B>A</B></C>\n')
+
+    def test_toprettyxml_with_dtd(self):
+        decl = '<?xml version="1.0" ?>\n'
+        # only whitespace in element content is ignorable
+        doctype = ('<!DOCTYPE C [<!ELEMENT C (#PCDATA|B)*>'
+                   '<!ELEMENT B (#PCDATA)>]>')
+        self.assertEqual(
+            parseString(doctype + '<C><B>A</B><B>A</B></C>').toprettyxml(),
+            decl + doctype + '\n<C><B>A</B><B>A</B></C>\n')
+        doctype = '<!DOCTYPE C [<!ELEMENT C (B)*><!ELEMENT B (#PCDATA)>]>'
+        self.assertEqual(
+            parseString(doctype + '<C><B>A</B><B>A</B></C>').toprettyxml(),
+            decl + doctype + '\n<C>\n\t<B>A</B>\n\t<B>A</B>\n</C>\n')
+
+    def test_toprettyxml_with_cdata_section(self):
+        decl = '<?xml version="1.0" ?>\n'
+        self.assertEqual(
+            parseString('<C><![CDATA[A]]><B>A</B></C>').toprettyxml(),
+            decl + '<C><![CDATA[A]]><B>A</B></C>\n')
 
     def test_toprettyxml_preserves_content_of_text_node(self):
-        # see issue #4147
+        # see gh-48397
         for str in ('<B>A</B>', '<A><B>C</B></A>'):
             dom = parseString(str)
             dom2 = parseString(dom.toprettyxml())
diff --git a/Lib/test/test_xml_etree.py b/Lib/test/test_xml_etree.py
index f87a47045dd1713..a73db445590032f 100644
--- a/Lib/test/test_xml_etree.py
+++ b/Lib/test/test_xml_etree.py
@@ -773,9 +773,10 @@ def test_indent(self):
         ET.indent(elem)
         self.assertEqual(ET.tostring(elem), b'<html>\n  
<body>text</body>\n</html>')
 
+        # an element with mixed content is not indented
         elem = ET.XML("<html><body>text</body>tail</html>")
         ET.indent(elem)
-        self.assertEqual(ET.tostring(elem), b'<html>\n  
<body>text</body>tail</html>')
+        self.assertEqual(ET.tostring(elem), 
b'<html><body>text</body>tail</html>')
 
         elem = 
ET.XML("<html><body><p>par</p>\n<p>text</p>\t<p><br/></p></body></html>")
         ET.indent(elem)
@@ -851,9 +852,39 @@ def test_indent_non_xml_whitespace(self):
         ET.indent(elem)
         self.assertEqual(
             ET.tostring(elem),
-            b'<html>&#160;<body>\n    <p>text</p>&#160;</body>\n</html>'
+            b'<html>&#160;<body><p>text</p>&#160;</body></html>'
         )
 
+    def test_indent_preserve(self):
+        # xml:space="preserve" applies to the whole subtree
+        elem = ET.XML('<html xml:space="preserve"> <body><p>text</p></body> 
</html>')
+        ET.indent(elem)
+        self.assertEqual(
+            ET.tostring(elem),
+            b'<html xml:space="preserve"> <body><p>text</p></body> </html>'
+        )
+        # other values do not preserve whitespace
+        elem = ET.XML('<html 
xml:space="default"><body><p>text</p></body></html>')
+        ET.indent(elem)
+        self.assertEqual(
+            ET.tostring(elem),
+            b'<html xml:space="default">\n'
+            b'  <body>\n'
+            b'    <p>text</p>\n'
+            b'  </body>\n'
+            b'</html>'
+        )
+
+    def test_indent_mixed_content(self):
+        # whitespace in an element which contains text is significant
+        elem = ET.XML('<p>hello <b>x</b> <i>y</i></p>')
+        ET.indent(elem)
+        self.assertEqual(ET.tostring(elem), b'<p>hello <b>x</b> <i>y</i></p>')
+        # the subtree of such element is not indented either
+        elem = ET.XML('<p>hello <b><i>y</i></b></p>')
+        ET.indent(elem)
+        self.assertEqual(ET.tostring(elem), b'<p>hello <b><i>y</i></b></p>')
+
     def test_indent_level(self):
         elem = 
ET.XML("<html><body><p>pre<br/>post</p><p>text</p></body></html>")
         with self.assertRaises(ValueError):
diff --git a/Lib/xml/dom/minidom.py b/Lib/xml/dom/minidom.py
index 7639fa14c5050fb..93c2e0638493e33 100644
--- a/Lib/xml/dom/minidom.py
+++ b/Lib/xml/dom/minidom.py
@@ -940,6 +940,10 @@ def writexml(self, writer, indent="", addindent="", 
newl=""):
                 self.childNodes[0].nodeType in (
                         Node.TEXT_NODE, Node.CDATA_SECTION_NODE)):
                 self.childNodes[0].writexml(writer, '', '', '')
+            elif self._preserves_whitespace():
+                # Adding whitespace here would change the content.
+                for node in self.childNodes:
+                    node.writexml(writer, '', '', '')
             else:
                 writer.write(newl)
                 for node in self.childNodes:
@@ -949,6 +953,25 @@ def writexml(self, writer, indent="", addindent="", 
newl=""):
         else:
             writer.write("/>%s"%(newl))
 
+    def _preserves_whitespace(self):
+        """Returns true iff whitespace in the content is significant.
+
+        This is the case if the element is marked with xml:space="preserve",
+        if the DTD declares that its content model is not element content,
+        or, in absence of such declaration, if it contains text.
+        """
+        if self.getAttribute("xml:space") == "preserve":
+            return True
+        doc = self.ownerDocument
+        info = doc and doc._get_elem_info(self)
+        if info is not None:
+            # Only whitespace in element content is ignorable
+            # (see XML 1.0, 3.2.1).
+            return not info.isElementContent()
+        return any(node.nodeType in (Node.TEXT_NODE, Node.CDATA_SECTION_NODE)
+                   and node.data.strip(_XML_WHITESPACE)
+                   for node in self.childNodes)
+
     def _get_attributes(self):
         self._ensure_attributes()
         return NamedNodeMap(self._attrs, self._attrsNS, self)
diff --git a/Lib/xml/etree/ElementTree.py b/Lib/xml/etree/ElementTree.py
index 3b4bfa3bd483c2d..65c8e6e7461f1c2 100644
--- a/Lib/xml/etree/ElementTree.py
+++ b/Lib/xml/etree/ElementTree.py
@@ -104,6 +104,9 @@
 # The white space characters of the XML specification (see XML 1.0, 2.3).
 _XML_WHITESPACE = " \t\r\n"
 
+# The xml:space attribute (see XML 1.0, 2.10).
+_XML_SPACE = "{http://www.w3.org/XML/1998/namespace}space";
+
 class ParseError(SyntaxError):
     """An error when parsing an XML document.
 
@@ -1196,7 +1199,20 @@ def indent(tree, space="  ", level=0):
     # Reduce the memory consumption by reusing indentation strings.
     indentations = ["\n" + level * space]
 
+    def _preserves_whitespace(elem):
+        # True iff whitespace in the content of the element is significant.
+        if elem.get(_XML_SPACE) == "preserve":
+            return True
+        if elem.text and elem.text.strip(_XML_WHITESPACE):
+            return True
+        return any(child.tail and child.tail.strip(_XML_WHITESPACE)
+                   for child in elem)
+
     def _indent_children(elem, level):
+        if _preserves_whitespace(elem):
+            # Adding whitespace here would change the content.
+            return
+
         # Start a new indentation level for the first child.
         child_level = level + 1
         try:
@@ -1205,18 +1221,15 @@ def _indent_children(elem, level):
             child_indentation = indentations[level] + space
             indentations.append(child_indentation)
 
-        if not elem.text or not elem.text.strip(_XML_WHITESPACE):
-            elem.text = child_indentation
+        elem.text = child_indentation
 
         for child in elem:
             if len(child):
                 _indent_children(child, child_level)
-            if not child.tail or not child.tail.strip(_XML_WHITESPACE):
-                child.tail = child_indentation
+            child.tail = child_indentation
 
         # Dedent after the last child by overwriting the previous indentation.
-        if not child.tail.strip(_XML_WHITESPACE):
-            child.tail = indentations[level]
+        child.tail = indentations[level]
 
     _indent_children(tree, 0)
 
diff --git 
a/Misc/NEWS.d/next/Library/2026-08-30-18-00-00.gh-issue-81623.Vh2Kt6.rst 
b/Misc/NEWS.d/next/Library/2026-08-30-18-00-00.gh-issue-81623.Vh2Kt6.rst
new file mode 100644
index 000000000000000..bdc02a4c55ee03b
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-08-30-18-00-00.gh-issue-81623.Vh2Kt6.rst
@@ -0,0 +1,6 @@
+:meth:`~xml.dom.minidom.Node.toprettyxml` in :mod:`xml.dom.minidom` and
+:func:`~xml.etree.ElementTree.indent` in :mod:`xml.etree.ElementTree` no longer
+add whitespace inside an element which is marked with ``xml:space="preserve"``
+or which contains text (:meth:`!toprettyxml` also takes into account the
+content model declared in the DTD).  Previously such indentation changed the
+content of the element.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to