https://github.com/python/cpython/commit/ce32912328e1b89f12abbc6d8010cc1777390333
commit: ce32912328e1b89f12abbc6d8010cc1777390333
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-09-13T17:21:03Z
summary:

gh-61290: Fix serializing attributes with the default_namespace option 
(GH-156747)

Serialization with the default_namespace option failed for any attribute
without a namespace.  But a default namespace declaration does not apply
to attribute names, so an unqualified attribute name is written as is.
For the same reason a qualified attribute name in the default namespace is
now written with a prefix; it was written without one, which lost its
namespace (gh-113581).

files:
A Misc/NEWS.d/next/Library/2026-09-01-00-30-00.gh-issue-61290.Vx7pQ2.rst
M Lib/test/test_xml_etree.py
M Lib/xml/etree/ElementTree.py

diff --git a/Lib/test/test_xml_etree.py b/Lib/test/test_xml_etree.py
index a73db445590032..899947c1f8e0d7 100644
--- a/Lib/test/test_xml_etree.py
+++ b/Lib/test/test_xml_etree.py
@@ -941,6 +941,70 @@ def 
test_tostring_default_namespace_original_no_namespace(self):
         with self.assertRaisesRegex(ValueError, EXPECTED_MSG):
             ET.tostring(elem, encoding='unicode', default_namespace='foobar')
 
+    def test_tostring_default_namespace_attributes(self):
+        # gh-61290: the default namespace does not apply to attribute names
+        elem = ET.XML('<body xmlns="http://effbot.org/ns"; attr="value">'
+                      '<tag attr="value" /></body>')
+        self.assertEqual(
+            ET.tostring(elem, encoding='unicode',
+                        default_namespace='http://effbot.org/ns'),
+            '<body xmlns="http://effbot.org/ns"; attr="value">'
+            '<tag attr="value" /></body>'
+        )
+
+    def test_tostring_default_namespace_qualified_attributes(self):
+        # a qualified attribute name always needs a prefix, even if it is
+        # in the default namespace
+        elem = ET.Element('{http://effbot.org/ns}body',
+                          {'{http://effbot.org/ns}attr': 'value'})
+        self.assertEqual(
+            ET.tostring(elem, encoding='unicode',
+                        default_namespace='http://effbot.org/ns'),
+            '<body xmlns="http://effbot.org/ns"; '
+            'xmlns:ns1="http://effbot.org/ns"; ns1:attr="value" />'
+        )
+        # an attribute in another namespace uses the prefix of that namespace
+        elem = ET.Element('{http://effbot.org/ns}body',
+                          {'{foobar}attr': 'value', 'plain': 'value'})
+        self.assertEqual(
+            ET.tostring(elem, encoding='unicode',
+                        default_namespace='http://effbot.org/ns'),
+            '<body xmlns="http://effbot.org/ns"; xmlns:ns1="foobar" '
+            'ns1:attr="value" plain="value" />'
+        )
+
+    def test_tostring_default_namespace_attributes_round_trip(self):
+        xml = ('<body xmlns="http://effbot.org/ns"; xmlns:ns1="foobar" '
+               'attr="1"><tag ns1:attr="2" /></body>')
+        elem = ET.XML(xml)
+        self.assertEqual(
+            ET.tostring(elem, encoding='unicode',
+                        default_namespace='http://effbot.org/ns'),
+            xml
+        )
+        self.assertEqual(
+            [sorted(e.attrib.items()) for e in ET.XML(xml).iter()],
+            [sorted(e.attrib.items()) for e in elem.iter()]
+        )
+
+    def test_tostring_default_namespace_registered_empty_prefix(self):
+        # gh-118416: the empty prefix is registered for other namespace,
+        # so it cannot be used for the default namespace
+        nsmap = ET.register_namespace._namespace_map
+        self.addCleanup(nsmap.pop, 'default', None)
+        ET.register_namespace('', 'default')
+        elem = ET.Element('{default}elem')
+        self.assertEqual(
+            ET.tostring(elem, encoding='unicode',
+                        default_namespace='otherdefault'),
+            '<ns1:elem xmlns="otherdefault" xmlns:ns1="default" />'
+        )
+        # without the option the registered prefix is used
+        self.assertEqual(
+            ET.tostring(elem, encoding='unicode'),
+            '<elem xmlns="default" />'
+        )
+
     def test_tostring_no_xml_declaration(self):
         elem = ET.XML('<body><tag/></body>')
         self.assertEqual(
@@ -1010,6 +1074,14 @@ def test_tostring_xml_declaration_cases(self):
                     expected_retval
                 )
 
+    def test_tostring_default_namespace_attributes_html(self):
+        elem = ET.XML('<body xmlns="http://effbot.org/ns"; attr="value" />')
+        self.assertEqual(
+            ET.tostring(elem, encoding='unicode', method='html',
+                        default_namespace='http://effbot.org/ns'),
+            '<body xmlns="http://effbot.org/ns"; attr="value"></body>'
+        )
+
     def test_tostring_standalone(self):
         elem = ET.XML('<body><tag/></body>')
         self.assertEqual(
diff --git a/Lib/xml/etree/ElementTree.py b/Lib/xml/etree/ElementTree.py
index 65c8e6e7461f1c..0cd04ead801600 100644
--- a/Lib/xml/etree/ElementTree.py
+++ b/Lib/xml/etree/ElementTree.py
@@ -757,9 +757,10 @@ def write(self, file_or_filename,
             if method == "text":
                 _serialize_text(write, self._root)
             else:
-                qnames, namespaces = _namespaces(self._root, default_namespace)
+                qnames, attr_qnames, namespaces = _namespaces(
+                    self._root, default_namespace)
                 serialize = _serialize[method]
-                serialize(write, self._root, qnames, namespaces,
+                serialize(write, self._root, qnames, attr_qnames, namespaces,
                           short_empty_elements=short_empty_elements)
 
 # --------------------------------------------------------------------
@@ -820,28 +821,59 @@ def _namespaces(elem, default_namespace=None):
 
     # maps qnames to *encoded* prefix:local names
     qnames = {None: None}
+    # The default namespace declaration does not apply to attribute names,
+    # so they are encoded separately: an unqualified name is left as is,
+    # and a qualified name always gets a prefix.
+    attr_qnames = {None: None} if default_namespace else qnames
 
-    # maps uri:s to prefixes
+    # maps prefixes to uri:s
     namespaces = {}
+    # maps uri:s to prefixes, "" is the prefix of the default namespace
+    prefixes = {}
+    # maps uri:s to prefixes usable in attribute names
+    attr_prefixes = {} if default_namespace else prefixes
     if default_namespace:
-        namespaces[default_namespace] = ""
-
-    def add_qname(qname):
+        namespaces[""] = default_namespace
+        prefixes[default_namespace] = ""
+
+    def get_prefix(uri, isattr):
+        # find or create the prefix for the namespace uri
+        if isattr:
+            prefix = attr_prefixes.get(uri)
+            if prefix is None:
+                # the empty prefix is of no use for an attribute name
+                prefix = prefixes.get(uri) or None
+        else:
+            prefix = prefixes.get(uri)
+        if prefix is not None:
+            return prefix
+        prefix = _namespace_map.get(uri)
+        if prefix is None or not prefix and (isattr or default_namespace):
+            # the empty prefix is of no use for an attribute name,
+            # and the default namespace is used for other uri
+            prefix = "ns%d" % len(namespaces)
+        if prefix != "xml":
+            namespaces[prefix] = uri
+        if isattr:
+            attr_prefixes[uri] = prefix
+        prefixes.setdefault(uri, prefix)
+        return prefix
+
+    def add_qname(qname, isattr=False):
         # calculate serialized qname representation
         try:
             if qname[:1] == "{":
                 uri, tag = qname[1:].rsplit("}", 1)
-                prefix = namespaces.get(uri)
-                if prefix is None:
-                    prefix = _namespace_map.get(uri)
-                    if prefix is None:
-                        prefix = "ns%d" % len(namespaces)
-                    if prefix != "xml":
-                        namespaces[uri] = prefix
+                prefix = get_prefix(uri, isattr)
                 if prefix:
-                    qnames[qname] = "%s:%s" % (prefix, tag)
+                    if isattr:
+                        attr_qnames[qname] = "%s:%s" % (prefix, tag)
+                    else:
+                        qnames[qname] = "%s:%s" % (prefix, tag)
                 else:
                     qnames[qname] = tag # default element
+            elif isattr:
+                attr_qnames[qname] = qname
             else:
                 if default_namespace:
                     # FIXME: can this be handled in XML 1.0?
@@ -867,16 +899,16 @@ def add_qname(qname):
         for key, value in elem.items():
             if isinstance(key, QName):
                 key = key.text
-            if key not in qnames:
-                add_qname(key)
+            if key not in attr_qnames:
+                add_qname(key, isattr=True)
             if isinstance(value, QName) and value.text not in qnames:
                 add_qname(value.text)
         text = elem.text
         if isinstance(text, QName) and text.text not in qnames:
             add_qname(text.text)
-    return qnames, namespaces
+    return qnames, attr_qnames, namespaces
 
-def _serialize_xml(write, elem, qnames, namespaces,
+def _serialize_xml(write, elem, qnames, attr_qnames, namespaces,
                    short_empty_elements, **kwargs):
     tag = elem.tag
     text = elem.text
@@ -890,15 +922,14 @@ def _serialize_xml(write, elem, qnames, namespaces,
             if text:
                 write(_escape_cdata(text))
             for e in elem:
-                _serialize_xml(write, e, qnames, None,
+                _serialize_xml(write, e, qnames, attr_qnames, None,
                                short_empty_elements=short_empty_elements)
         else:
             write("<" + tag)
             items = list(elem.items())
             if items or namespaces:
                 if namespaces:
-                    for v, k in sorted(namespaces.items(),
-                                       key=lambda x: x[1]):  # sort on prefix
+                    for k, v in sorted(namespaces.items()):  # sort on prefix
                         if k:
                             k = ":" + k
                         write(" xmlns%s=\"%s\"" % (
@@ -912,13 +943,13 @@ def _serialize_xml(write, elem, qnames, namespaces,
                         v = qnames[v.text]
                     else:
                         v = _escape_attrib(v)
-                    write(" %s=\"%s\"" % (qnames[k], v))
+                    write(" %s=\"%s\"" % (attr_qnames[k], v))
             if text or len(elem) or not short_empty_elements:
                 write(">")
                 if text:
                     write(_escape_cdata(text))
                 for e in elem:
-                    _serialize_xml(write, e, qnames, None,
+                    _serialize_xml(write, e, qnames, attr_qnames, None,
                                    short_empty_elements=short_empty_elements)
                 write("</" + tag + ">")
             else:
@@ -933,7 +964,7 @@ def _serialize_xml(write, elem, qnames, namespaces,
               "img", "input", "isindex", "link", "meta", "param", "source",
               "track", "wbr", "plaintext"}
 
-def _serialize_html(write, elem, qnames, namespaces, **kwargs):
+def _serialize_html(write, elem, qnames, attr_qnames, namespaces, **kwargs):
     tag = elem.tag
     text = elem.text
     if tag is Comment:
@@ -946,14 +977,13 @@ def _serialize_html(write, elem, qnames, namespaces, 
**kwargs):
             if text:
                 write(_escape_cdata(text))
             for e in elem:
-                _serialize_html(write, e, qnames, None)
+                _serialize_html(write, e, qnames, attr_qnames, None)
         else:
             write("<" + tag)
             items = list(elem.items())
             if items or namespaces:
                 if namespaces:
-                    for v, k in sorted(namespaces.items(),
-                                       key=lambda x: x[1]):  # sort on prefix
+                    for k, v in sorted(namespaces.items()):  # sort on prefix
                         if k:
                             k = ":" + k
                         write(" xmlns%s=\"%s\"" % (
@@ -963,7 +993,7 @@ def _serialize_html(write, elem, qnames, namespaces, 
**kwargs):
                 for k, v in items:
                     if isinstance(k, QName):
                         k = k.text
-                    k = qnames[k]
+                    k = attr_qnames[k]
                     if v is None:
                         write(" %s" % k)  # empty attr
                     else:
@@ -980,7 +1010,7 @@ def _serialize_html(write, elem, qnames, namespaces, 
**kwargs):
                 else:
                     write(_escape_cdata(text))
             for e in elem:
-                _serialize_html(write, e, qnames, None)
+                _serialize_html(write, e, qnames, attr_qnames, None)
             if ltag not in HTML_EMPTY:
                 write("</" + tag + ">")
     if elem.tail:
diff --git 
a/Misc/NEWS.d/next/Library/2026-09-01-00-30-00.gh-issue-61290.Vx7pQ2.rst 
b/Misc/NEWS.d/next/Library/2026-09-01-00-30-00.gh-issue-61290.Vx7pQ2.rst
new file mode 100644
index 00000000000000..585ba119c44b42
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-09-01-00-30-00.gh-issue-61290.Vx7pQ2.rst
@@ -0,0 +1,5 @@
+:mod:`xml.etree.ElementTree` no longer refuses to serialize attributes
+without a namespace when the *default_namespace* option is used.
+The default namespace declaration does not apply to attribute names,
+so an unqualified attribute name is written as is,
+and a qualified attribute name is always written with a prefix.

_______________________________________________
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