diff --git a/Lib/test/test_xml_etree.py b/Lib/test/test_xml_etree.py
index 2af2d1fd64520b1..347b698bba5976e 100644
--- a/Lib/test/test_xml_etree.py
+++ b/Lib/test/test_xml_etree.py
@@ -901,6 +901,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('
'
+ '')
+ self.assertEqual(
+ ET.tostring(elem, encoding='unicode',
+ default_namespace='http://effbot.org/ns'),
+ ''
+ ''
+ )
+
+ 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'),
+ '')
+ 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'),
+ ''
+ )
+ # without the option the registered prefix is used
+ self.assertEqual(
+ ET.tostring(elem, encoding='unicode'),
+ ''
+ )
+
def test_tostring_no_xml_declaration(self):
elem = ET.XML('')
self.assertEqual(
@@ -970,6 +1034,14 @@ def test_tostring_xml_declaration_cases(self):
expected_retval
)
+ def test_tostring_default_namespace_attributes_html(self):
+ elem = ET.XML(''
+ )
+
def test_tostringlist_default_namespace(self):
elem = ET.XML('')
self.assertEqual(
diff --git a/Lib/xml/etree/ElementTree.py b/Lib/xml/etree/ElementTree.py
index 951540eb9f45e90..a4aecfc8822c70a 100644
--- a/Lib/xml/etree/ElementTree.py
+++ b/Lib/xml/etree/ElementTree.py
@@ -743,9 +743,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)
# --------------------------------------------------------------------
@@ -806,28 +807,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?
@@ -853,16 +885,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
@@ -876,15 +908,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\"" % (
@@ -898,13 +929,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:
@@ -919,7 +950,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:
@@ -932,14 +963,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\"" % (
@@ -949,7 +979,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:
@@ -966,7 +996,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 000000000000000..585ba119c44b427
--- /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.