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.