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'), + '' + ) + # 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'), + '' + ) + + def test_tostring_default_namespace_attributes_round_trip(self): + xml = ('') + 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('') + self.assertEqual( + ET.tostring(elem, encoding='unicode', method='html', + default_namespace='http://effbot.org/ns'), + '' + ) + 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("") 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("") 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.