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

gh-44376: Write missing namespace declarations in xml.dom.minidom (GH-156674)

Element.writexml() now writes the xmlns declarations needed for the
namespaces of the element and its attributes, if they are not already
declared for an ancestor.  A prefix in scope is reused for an attribute
in a namespace without a prefix, or a new one is invented, because
attributes cannot use the default namespace.  The document is not
modified by the serialization.

files:
A Misc/NEWS.d/next/Library/2026-08-30-12-00-00.gh-issue-44376.Kp4Vz9.rst
M Doc/library/xml.dom.minidom.rst
M Lib/test/test_minidom.py
M Lib/xml/dom/minidom.py

diff --git a/Doc/library/xml.dom.minidom.rst b/Doc/library/xml.dom.minidom.rst
index 3e1f7a7e12a94e..39c41cb9a30317 100644
--- a/Doc/library/xml.dom.minidom.rst
+++ b/Doc/library/xml.dom.minidom.rst
@@ -154,6 +154,10 @@ module documentation.  This section lists the differences 
between the API and
    .. versionchanged:: 3.9
       The *standalone* parameter was added.
 
+   .. versionchanged:: next
+      Namespace declarations missing for the serialized element
+      and its attributes are now written.
+
 .. method:: Node.toxml(encoding=None, standalone=None)
 
    Return a string or byte string containing the XML represented by
diff --git a/Lib/test/test_minidom.py b/Lib/test/test_minidom.py
index 3cc99e36e8898f..37829eaef0e5d4 100644
--- a/Lib/test/test_minidom.py
+++ b/Lib/test/test_minidom.py
@@ -571,6 +571,130 @@ def testWriteXML(self):
         dom.unlink()
         self.assertEqual(str, domstr)
 
+    def testWriteXMLNamespaceDeclarations(self):
+        dom = Document()
+        root = dom.appendChild(
+            dom.createElementNS("http://xml.python.org/ns";, "p:root"))
+        child = root.appendChild(
+            dom.createElementNS("http://xml.python.org/ns";, "p:child"))
+        child.setAttributeNS("http://xml.python.org/ns2";, "q:attr", "value")
+        self.assertEqual(dom.documentElement.toxml(),
+                '<p:root xmlns:p="http://xml.python.org/ns";>'
+                '<p:child xmlns:q="http://xml.python.org/ns2"; '
+                'q:attr="value"/></p:root>')
+        dom.unlink()
+
+    def testWriteXMLDefaultNamespace(self):
+        dom = Document()
+        root = dom.appendChild(
+            dom.createElementNS("http://xml.python.org/ns";, "root"))
+        root.appendChild(
+            dom.createElementNS("http://xml.python.org/ns";, "child"))
+        # An element in no namespace undeclares the default namespace.
+        root.appendChild(dom.createElement("nons"))
+        self.assertEqual(dom.documentElement.toxml(),
+                '<root xmlns="http://xml.python.org/ns";>'
+                '<child/><nons xmlns=""/></root>')
+        dom.unlink()
+
+    def testWriteXMLAttributeNamespacePrefix(self):
+        dom = Document()
+        root = dom.appendChild(dom.createElement("root"))
+        # Attributes cannot use the default namespace, a prefix is invented.
+        root.setAttributeNS("http://xml.python.org/ns";, "attr", "value")
+        root.setAttributeNS("http://xml.python.org/ns2";, "attr2", "value2")
+        self.assertEqual(dom.documentElement.toxml(),
+                '<root xmlns:ns0="http://xml.python.org/ns"; '
+                'xmlns:ns1="http://xml.python.org/ns2"; '
+                'ns0:attr="value" ns1:attr2="value2"/>')
+        # The same namespace gets the same prefix.
+        root.setAttributeNS("http://xml.python.org/ns";, "attr3", "value3")
+        self.assertEqual(dom.documentElement.toxml(),
+                '<root xmlns:ns0="http://xml.python.org/ns"; '
+                'xmlns:ns1="http://xml.python.org/ns2"; '
+                'ns0:attr="value" ns1:attr2="value2" ns0:attr3="value3"/>')
+        dom.unlink()
+
+    def testWriteXMLAttributeNamespacePrefixReused(self):
+        # A prefix already bound to the namespace of the attribute is used.
+        dom = Document()
+        root = dom.appendChild(
+            dom.createElementNS("http://xml.python.org/ns";, "p:root"))
+        root.setAttributeNS("http://xml.python.org/ns";, "attr", "value")
+        self.assertEqual(dom.documentElement.toxml(),
+                '<p:root xmlns:p="http://xml.python.org/ns"; p:attr="value"/>')
+        # The prefix can be bound for an ancestor.
+        child = root.appendChild(dom.createElement("child"))
+        child.setAttributeNS("http://xml.python.org/ns";, "attr", "value")
+        self.assertEqual(child.toxml(), '<child p:attr="value"/>')
+        # The prefix bound for a preceding attribute is reused.
+        root.setAttributeNS("http://xml.python.org/ns3";, "q:attr3", "value3")
+        root.setAttributeNS("http://xml.python.org/ns3";, "attr4", "value4")
+        self.assertEqual(dom.documentElement.toxml(),
+                '<p:root xmlns:p="http://xml.python.org/ns"; '
+                'xmlns:q="http://xml.python.org/ns3"; '
+                'p:attr="value" q:attr3="value3" q:attr4="value4">'
+                '<child p:attr="value"/></p:root>')
+        root.removeAttributeNS("http://xml.python.org/ns3";, "attr3")
+        root.removeAttributeNS("http://xml.python.org/ns3";, "attr4")
+        # The prefix must not be taken by an explicit declaration.
+        root.setAttributeNS(xml.dom.XMLNS_NAMESPACE, "xmlns:ns0", "other")
+        root.setAttributeNS("http://xml.python.org/ns2";, "attr2", "value2")
+        self.assertEqual(dom.documentElement.toxml(),
+                '<p:root xmlns:p="http://xml.python.org/ns"; '
+                'xmlns:ns1="http://xml.python.org/ns2"; '
+                'p:attr="value" xmlns:ns0="other" ns1:attr2="value2">'
+                '<child p:attr="value"/></p:root>')
+        dom.unlink()
+
+    def testWriteXMLXMLPrefix(self):
+        dom = Document()
+        root = dom.appendChild(dom.createElement("root"))
+        # The "xml" prefix is bound by definition and is never declared.
+        root.setAttributeNS(xml.dom.XML_NAMESPACE, "xml:lang", "en")
+        self.assertEqual(dom.documentElement.toxml(), '<root xml:lang="en"/>')
+        dom.unlink()
+
+    def testWriteXMLExistingNamespaceDeclarations(self):
+        for str in [
+            '<p:root xmlns:p="http://xml.python.org/ns";><p:child/></p:root>',
+            '<root xmlns="http://xml.python.org/ns";><child xmlns=""/></root>',
+            '<p:root xmlns:p="http://xml.python.org/ns";>'
+                '<p:child xmlns:p="http://xml.python.org/ns2"/></p:root>',
+            '<root xmlns:p="http://xml.python.org/ns"; p:attr="value"/>',
+        ]:
+            with self.subTest(str=str):
+                dom = parseString(str)
+                self.assertEqual(dom.documentElement.toxml(), str)
+                dom.unlink()
+
+    def testWriteXMLNotANamespaceDeclaration(self):
+        # an attribute whose name only starts with "xmlns" is not one
+        dom = parseString('<root xmlns="http://xml.python.org/ns";>'
+                          '<child xmlnsabc="v"><g/></child></root>')
+        self.assertEqual(dom.documentElement.toxml(),
+                '<root xmlns="http://xml.python.org/ns";>'
+                '<child xmlnsabc="v"><g/></child></root>')
+        dom.unlink()
+
+        dom = Document()
+        root = dom.appendChild(
+            dom.createElementNS("http://xml.python.org/ns";, "root"))
+        child = root.appendChild(dom.createElement("child"))
+        child.setAttribute("xmlnsabc", "v")
+        self.assertEqual(dom.documentElement.toxml(),
+                '<root xmlns="http://xml.python.org/ns";>'
+                '<child xmlns="" xmlnsabc="v"/></root>')
+        dom.unlink()
+
+    def testWriteXMLDoesNotModifyDocument(self):
+        dom = Document()
+        root = dom.appendChild(
+            dom.createElementNS("http://xml.python.org/ns";, "p:root"))
+        root.toxml()
+        self.assertEqual(root.attributes.length, 0)
+        dom.unlink()
+
     def test_toxml_quote_text(self):
         dom = Document()
         elem = dom.appendChild(dom.createElement('elem'))
diff --git a/Lib/xml/dom/minidom.py b/Lib/xml/dom/minidom.py
index 2edd3f438e686d..d62564da3f36c6 100644
--- a/Lib/xml/dom/minidom.py
+++ b/Lib/xml/dom/minidom.py
@@ -379,6 +379,109 @@ def _write_data(writer, text, attr):
             text = text.replace("\t", "&#9;")
     writer.write(text)
 
+
+# The "xml" prefix is bound by definition and is never declared.
+_ROOT_NSMAP = {"xml": XML_NAMESPACE}
+
+
+def _bind_namespace(nsmap, inherited, prefix, uri):
+    """Bind *prefix* in *nsmap*, copying it if it is still the inherited 
one."""
+    if nsmap is inherited:
+        nsmap = dict(inherited)
+    nsmap[prefix] = uri
+    return nsmap
+
+
+def _in_scope_namespaces(element):
+    """Return the namespaces in scope for *element*, as written by writexml."""
+    ancestors = []
+    node = element.parentNode
+    while node is not None and node.nodeType == Node.ELEMENT_NODE:
+        ancestors.append(node)
+        node = node.parentNode
+    nsmap = _ROOT_NSMAP
+    for node in reversed(ancestors):
+        nsmap, _ = _fixup_namespaces(node, nsmap)
+    return nsmap
+
+
+def _fixup_namespaces(element, nsmap):
+    """Compute namespace declarations missing for the serialized element.
+
+    *nsmap* is the mapping of prefixes to namespace URIs in scope for the
+    element.  Return the mapping in scope for its children and the list of
+    (name, value) pairs of the attributes to be written, starting with the
+    added namespace declarations.  The element and its attributes are not
+    modified.
+    """
+    attrs = element._attrs
+    uri = element.namespaceURI
+    if not attrs and not uri and not nsmap.get(None):
+        # Neither the element nor its attributes need a declaration.
+        return nsmap, ()
+
+    inherited = nsmap
+    declarations = []
+    # (name, value, namespace URI, attribute) of the attributes to write.
+    entries = []
+    if attrs:
+        for attr in attrs.values():
+            name = attr.name
+            attr_uri = attr.namespaceURI
+            if (attr_uri == XMLNS_NAMESPACE or name == "xmlns"
+                    or name.startswith("xmlns:")):
+                # Declarations already present in the document take precedence.
+                nsmap = _bind_namespace(
+                    nsmap, inherited,
+                    attr.localName if attr.prefix else None, attr.value)
+                attr_uri = None
+            elif attr_uri == XML_NAMESPACE:
+                # The xml prefix is bound by definition.
+                attr_uri = None
+            entries.append((name, attr.value, attr_uri, attr))
+
+    if uri:
+        prefix, _, _ = element.tagName.rpartition(':')
+        prefix = prefix or None
+        if nsmap.get(prefix) != uri:
+            nsmap = _bind_namespace(nsmap, inherited, prefix, uri)
+            declarations.append(("xmlns:" + prefix if prefix else "xmlns", 
uri))
+    elif nsmap.get(None) and ':' not in element.tagName:
+        # The element is in no namespace, undeclare the default one.
+        nsmap = _bind_namespace(nsmap, inherited, None, None)
+        declarations.append(("xmlns", ""))
+
+    items = []
+    prefixes = None  # namespace URI -> prefix, built only when needed
+    n = 0
+    for name, value, attr_uri, attr in entries:
+        if attr_uri is not None:
+            # Unprefixed attributes are in no namespace, so an attribute
+            # in a namespace always needs a prefix.
+            prefix, _, _ = name.rpartition(':')
+            if not prefix:
+                # Reuse a prefix bound to the namespace, or invent one.
+                if prefixes is None:
+                    prefixes = {u: p for p, u in nsmap.items()
+                                if p is not None}
+                prefix = prefixes.get(attr_uri)
+                if prefix is None:
+                    while nsmap.get("ns%d" % n) is not None:
+                        n += 1
+                    prefix = "ns%d" % n
+                name = "%s:%s" % (prefix, attr.localName)
+            if nsmap.get(prefix) != attr_uri:
+                nsmap = _bind_namespace(nsmap, inherited, prefix, attr_uri)
+                declarations.append(("xmlns:" + prefix, attr_uri))
+                if prefixes is not None:
+                    prefixes[attr_uri] = prefix
+        items.append((name, value))
+
+    if declarations:
+        return nsmap, declarations + items
+    return nsmap, items
+
+
 def _get_elements_by_tagName_helper(parent, name, rc):
     for node in parent.childNodes:
         if node.nodeType == Node.ELEMENT_NODE and \
@@ -944,7 +1047,8 @@ def getElementsByTagNameNS(self, namespaceURI, localName):
     def __repr__(self):
         return "<DOM Element: %s at %#x>" % (self.tagName, id(self))
 
-    def writexml(self, writer, indent="", addindent="", newl=""):
+    def writexml(self, writer, indent="", addindent="", newl="", *,
+                 _nsmap=None):
         """Write an XML element to a file-like object
 
         Write the element to the writer object that must provide
@@ -953,13 +1057,14 @@ def writexml(self, writer, indent="", addindent="", 
newl=""):
         # indent = current indentation
         # addindent = indentation to add to higher levels
         # newl = newline string
+        if _nsmap is None:
+            _nsmap = _in_scope_namespaces(self)
         writer.write(indent+"<" + self.tagName)
 
-        attrs = self._get_attributes()
-
-        for a_name in attrs.keys():
+        nsmap, items = _fixup_namespaces(self, _nsmap)
+        for a_name, value in items:
             writer.write(" %s=\"" % a_name)
-            _write_data(writer, attrs[a_name].value, True)
+            _write_data(writer, value, True)
             writer.write("\"")
         if self.childNodes:
             writer.write(">")
@@ -974,7 +1079,15 @@ def writexml(self, writer, indent="", addindent="", 
newl=""):
             else:
                 writer.write(newl)
                 for node in self.childNodes:
-                    node.writexml(writer, indent+addindent, addindent, newl)
+                    if type(node).writexml is Element.writexml:
+                        # Pass the namespaces in scope to the standard
+                        # implementation; an overridden writexml() has the
+                        # documented signature and computes them itself.
+                        node.writexml(writer, indent+addindent, addindent,
+                                      newl, _nsmap=nsmap)
+                    else:
+                        node.writexml(writer, indent+addindent, addindent,
+                                      newl)
                 writer.write(indent)
             writer.write("</%s>%s" % (self.tagName, newl))
         else:
diff --git 
a/Misc/NEWS.d/next/Library/2026-08-30-12-00-00.gh-issue-44376.Kp4Vz9.rst 
b/Misc/NEWS.d/next/Library/2026-08-30-12-00-00.gh-issue-44376.Kp4Vz9.rst
new file mode 100644
index 00000000000000..5fce84ec0c52a8
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-08-30-12-00-00.gh-issue-44376.Kp4Vz9.rst
@@ -0,0 +1,4 @@
+:meth:`~xml.dom.minidom.Node.writexml` in :mod:`xml.dom.minidom` now writes
+the namespace declarations needed to serialize the namespaces of the element
+and its attributes, if they are not already declared for an ancestor.  The
+document is not modified.

_______________________________________________
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