From 6a76a10922086a106e71609e3c9376f5314e5e95 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 30 Aug 2026 19:17:10 +0300 Subject: [PATCH] gh-44376: Write missing namespace declarations in minidom Element.writexml() now emits the xmlns declarations needed to serialize the namespaces of the element and its attributes, if they are not already declared for an ancestor. A prefix is invented for a namespaced attribute without a prefix, because attributes cannot use the default namespace. The document is not modified by the serialization. The namespaces in scope are passed down the recursion as a keyword-only argument. They are passed only to the standard implementation of writexml(); an overridden method is called with the documented signature and computes them by walking the ancestors of the element. --- Doc/library/xml.dom.minidom.rst | 4 + Lib/test/test_minidom.py | 86 +++++++++++++ Lib/xml/dom/minidom.py | 119 ++++++++++++++++-- ...6-08-30-12-00-00.gh-issue-44376.Kp4Vz9.rst | 4 + 4 files changed, 206 insertions(+), 7 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-30-12-00-00.gh-issue-44376.Kp4Vz9.rst diff --git a/Doc/library/xml.dom.minidom.rst b/Doc/library/xml.dom.minidom.rst index efc81f31e36a5b..fd307b062515f3 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 3735a6046891ea..888376a39c84db 100644 --- a/Lib/test/test_minidom.py +++ b/Lib/test/test_minidom.py @@ -561,6 +561,92 @@ 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(), + '' + '') + 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(), + '' + '') + 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(), + '') + 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(), '') + dom.unlink() + + def testWriteXMLExistingNamespaceDeclarations(self): + for str in [ + '', + '', + '' + '', + '', + ]: + 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('' + '') + self.assertEqual(dom.documentElement.toxml(), + '' + '') + 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(), + '' + '') + 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 5fd3911bd3c9eb..e4adbd94555c07 100644 --- a/Lib/xml/dom/minidom.py +++ b/Lib/xml/dom/minidom.py @@ -19,7 +19,8 @@ import xml import xml.dom -from xml.dom import EMPTY_NAMESPACE, EMPTY_PREFIX, XMLNS_NAMESPACE, domreg +from xml.dom import (EMPTY_NAMESPACE, EMPTY_PREFIX, XML_NAMESPACE, + XMLNS_NAMESPACE, domreg) from xml.dom.minicompat import * from xml.dom.xmlbuilder import DOMImplementationLS, DocumentLS @@ -346,6 +347,100 @@ def _write_data(writer, text, attr): text = text.replace("\t", " ") 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 = [] + 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: + n = 0 + 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)) + 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 \ @@ -914,7 +1009,8 @@ def getElementsByTagNameNS(self, namespaceURI, localName): def __repr__(self): return "" % (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 @@ -923,13 +1019,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(">") @@ -940,7 +1037,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" % (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.