Skip to content

Commit ce32912

Browse files
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).
1 parent ad447fa commit ce32912

3 files changed

Lines changed: 136 additions & 29 deletions

File tree

Lib/test/test_xml_etree.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -941,6 +941,70 @@ def test_tostring_default_namespace_original_no_namespace(self):
941941
with self.assertRaisesRegex(ValueError, EXPECTED_MSG):
942942
ET.tostring(elem, encoding='unicode', default_namespace='foobar')
943943

944+
def test_tostring_default_namespace_attributes(self):
945+
# gh-61290: the default namespace does not apply to attribute names
946+
elem = ET.XML('<body xmlns="http://effbot.org/ns" attr="value">'
947+
'<tag attr="value" /></body>')
948+
self.assertEqual(
949+
ET.tostring(elem, encoding='unicode',
950+
default_namespace='http://effbot.org/ns'),
951+
'<body xmlns="http://effbot.org/ns" attr="value">'
952+
'<tag attr="value" /></body>'
953+
)
954+
955+
def test_tostring_default_namespace_qualified_attributes(self):
956+
# a qualified attribute name always needs a prefix, even if it is
957+
# in the default namespace
958+
elem = ET.Element('{http://effbot.org/ns}body',
959+
{'{http://effbot.org/ns}attr': 'value'})
960+
self.assertEqual(
961+
ET.tostring(elem, encoding='unicode',
962+
default_namespace='http://effbot.org/ns'),
963+
'<body xmlns="http://effbot.org/ns" '
964+
'xmlns:ns1="http://effbot.org/ns" ns1:attr="value" />'
965+
)
966+
# an attribute in another namespace uses the prefix of that namespace
967+
elem = ET.Element('{http://effbot.org/ns}body',
968+
{'{foobar}attr': 'value', 'plain': 'value'})
969+
self.assertEqual(
970+
ET.tostring(elem, encoding='unicode',
971+
default_namespace='http://effbot.org/ns'),
972+
'<body xmlns="http://effbot.org/ns" xmlns:ns1="foobar" '
973+
'ns1:attr="value" plain="value" />'
974+
)
975+
976+
def test_tostring_default_namespace_attributes_round_trip(self):
977+
xml = ('<body xmlns="http://effbot.org/ns" xmlns:ns1="foobar" '
978+
'attr="1"><tag ns1:attr="2" /></body>')
979+
elem = ET.XML(xml)
980+
self.assertEqual(
981+
ET.tostring(elem, encoding='unicode',
982+
default_namespace='http://effbot.org/ns'),
983+
xml
984+
)
985+
self.assertEqual(
986+
[sorted(e.attrib.items()) for e in ET.XML(xml).iter()],
987+
[sorted(e.attrib.items()) for e in elem.iter()]
988+
)
989+
990+
def test_tostring_default_namespace_registered_empty_prefix(self):
991+
# gh-118416: the empty prefix is registered for other namespace,
992+
# so it cannot be used for the default namespace
993+
nsmap = ET.register_namespace._namespace_map
994+
self.addCleanup(nsmap.pop, 'default', None)
995+
ET.register_namespace('', 'default')
996+
elem = ET.Element('{default}elem')
997+
self.assertEqual(
998+
ET.tostring(elem, encoding='unicode',
999+
default_namespace='otherdefault'),
1000+
'<ns1:elem xmlns="otherdefault" xmlns:ns1="default" />'
1001+
)
1002+
# without the option the registered prefix is used
1003+
self.assertEqual(
1004+
ET.tostring(elem, encoding='unicode'),
1005+
'<elem xmlns="default" />'
1006+
)
1007+
9441008
def test_tostring_no_xml_declaration(self):
9451009
elem = ET.XML('<body><tag/></body>')
9461010
self.assertEqual(
@@ -1010,6 +1074,14 @@ def test_tostring_xml_declaration_cases(self):
10101074
expected_retval
10111075
)
10121076

1077+
def test_tostring_default_namespace_attributes_html(self):
1078+
elem = ET.XML('<body xmlns="http://effbot.org/ns" attr="value" />')
1079+
self.assertEqual(
1080+
ET.tostring(elem, encoding='unicode', method='html',
1081+
default_namespace='http://effbot.org/ns'),
1082+
'<body xmlns="http://effbot.org/ns" attr="value"></body>'
1083+
)
1084+
10131085
def test_tostring_standalone(self):
10141086
elem = ET.XML('<body><tag/></body>')
10151087
self.assertEqual(

Lib/xml/etree/ElementTree.py

Lines changed: 59 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -757,9 +757,10 @@ def write(self, file_or_filename,
757757
if method == "text":
758758
_serialize_text(write, self._root)
759759
else:
760-
qnames, namespaces = _namespaces(self._root, default_namespace)
760+
qnames, attr_qnames, namespaces = _namespaces(
761+
self._root, default_namespace)
761762
serialize = _serialize[method]
762-
serialize(write, self._root, qnames, namespaces,
763+
serialize(write, self._root, qnames, attr_qnames, namespaces,
763764
short_empty_elements=short_empty_elements)
764765

765766
# --------------------------------------------------------------------
@@ -820,28 +821,59 @@ def _namespaces(elem, default_namespace=None):
820821

821822
# maps qnames to *encoded* prefix:local names
822823
qnames = {None: None}
824+
# The default namespace declaration does not apply to attribute names,
825+
# so they are encoded separately: an unqualified name is left as is,
826+
# and a qualified name always gets a prefix.
827+
attr_qnames = {None: None} if default_namespace else qnames
823828

824-
# maps uri:s to prefixes
829+
# maps prefixes to uri:s
825830
namespaces = {}
831+
# maps uri:s to prefixes, "" is the prefix of the default namespace
832+
prefixes = {}
833+
# maps uri:s to prefixes usable in attribute names
834+
attr_prefixes = {} if default_namespace else prefixes
826835
if default_namespace:
827-
namespaces[default_namespace] = ""
828-
829-
def add_qname(qname):
836+
namespaces[""] = default_namespace
837+
prefixes[default_namespace] = ""
838+
839+
def get_prefix(uri, isattr):
840+
# find or create the prefix for the namespace uri
841+
if isattr:
842+
prefix = attr_prefixes.get(uri)
843+
if prefix is None:
844+
# the empty prefix is of no use for an attribute name
845+
prefix = prefixes.get(uri) or None
846+
else:
847+
prefix = prefixes.get(uri)
848+
if prefix is not None:
849+
return prefix
850+
prefix = _namespace_map.get(uri)
851+
if prefix is None or not prefix and (isattr or default_namespace):
852+
# the empty prefix is of no use for an attribute name,
853+
# and the default namespace is used for other uri
854+
prefix = "ns%d" % len(namespaces)
855+
if prefix != "xml":
856+
namespaces[prefix] = uri
857+
if isattr:
858+
attr_prefixes[uri] = prefix
859+
prefixes.setdefault(uri, prefix)
860+
return prefix
861+
862+
def add_qname(qname, isattr=False):
830863
# calculate serialized qname representation
831864
try:
832865
if qname[:1] == "{":
833866
uri, tag = qname[1:].rsplit("}", 1)
834-
prefix = namespaces.get(uri)
835-
if prefix is None:
836-
prefix = _namespace_map.get(uri)
837-
if prefix is None:
838-
prefix = "ns%d" % len(namespaces)
839-
if prefix != "xml":
840-
namespaces[uri] = prefix
867+
prefix = get_prefix(uri, isattr)
841868
if prefix:
842-
qnames[qname] = "%s:%s" % (prefix, tag)
869+
if isattr:
870+
attr_qnames[qname] = "%s:%s" % (prefix, tag)
871+
else:
872+
qnames[qname] = "%s:%s" % (prefix, tag)
843873
else:
844874
qnames[qname] = tag # default element
875+
elif isattr:
876+
attr_qnames[qname] = qname
845877
else:
846878
if default_namespace:
847879
# FIXME: can this be handled in XML 1.0?
@@ -867,16 +899,16 @@ def add_qname(qname):
867899
for key, value in elem.items():
868900
if isinstance(key, QName):
869901
key = key.text
870-
if key not in qnames:
871-
add_qname(key)
902+
if key not in attr_qnames:
903+
add_qname(key, isattr=True)
872904
if isinstance(value, QName) and value.text not in qnames:
873905
add_qname(value.text)
874906
text = elem.text
875907
if isinstance(text, QName) and text.text not in qnames:
876908
add_qname(text.text)
877-
return qnames, namespaces
909+
return qnames, attr_qnames, namespaces
878910

879-
def _serialize_xml(write, elem, qnames, namespaces,
911+
def _serialize_xml(write, elem, qnames, attr_qnames, namespaces,
880912
short_empty_elements, **kwargs):
881913
tag = elem.tag
882914
text = elem.text
@@ -890,15 +922,14 @@ def _serialize_xml(write, elem, qnames, namespaces,
890922
if text:
891923
write(_escape_cdata(text))
892924
for e in elem:
893-
_serialize_xml(write, e, qnames, None,
925+
_serialize_xml(write, e, qnames, attr_qnames, None,
894926
short_empty_elements=short_empty_elements)
895927
else:
896928
write("<" + tag)
897929
items = list(elem.items())
898930
if items or namespaces:
899931
if namespaces:
900-
for v, k in sorted(namespaces.items(),
901-
key=lambda x: x[1]): # sort on prefix
932+
for k, v in sorted(namespaces.items()): # sort on prefix
902933
if k:
903934
k = ":" + k
904935
write(" xmlns%s=\"%s\"" % (
@@ -912,13 +943,13 @@ def _serialize_xml(write, elem, qnames, namespaces,
912943
v = qnames[v.text]
913944
else:
914945
v = _escape_attrib(v)
915-
write(" %s=\"%s\"" % (qnames[k], v))
946+
write(" %s=\"%s\"" % (attr_qnames[k], v))
916947
if text or len(elem) or not short_empty_elements:
917948
write(">")
918949
if text:
919950
write(_escape_cdata(text))
920951
for e in elem:
921-
_serialize_xml(write, e, qnames, None,
952+
_serialize_xml(write, e, qnames, attr_qnames, None,
922953
short_empty_elements=short_empty_elements)
923954
write("</" + tag + ">")
924955
else:
@@ -933,7 +964,7 @@ def _serialize_xml(write, elem, qnames, namespaces,
933964
"img", "input", "isindex", "link", "meta", "param", "source",
934965
"track", "wbr", "plaintext"}
935966

936-
def _serialize_html(write, elem, qnames, namespaces, **kwargs):
967+
def _serialize_html(write, elem, qnames, attr_qnames, namespaces, **kwargs):
937968
tag = elem.tag
938969
text = elem.text
939970
if tag is Comment:
@@ -946,14 +977,13 @@ def _serialize_html(write, elem, qnames, namespaces, **kwargs):
946977
if text:
947978
write(_escape_cdata(text))
948979
for e in elem:
949-
_serialize_html(write, e, qnames, None)
980+
_serialize_html(write, e, qnames, attr_qnames, None)
950981
else:
951982
write("<" + tag)
952983
items = list(elem.items())
953984
if items or namespaces:
954985
if namespaces:
955-
for v, k in sorted(namespaces.items(),
956-
key=lambda x: x[1]): # sort on prefix
986+
for k, v in sorted(namespaces.items()): # sort on prefix
957987
if k:
958988
k = ":" + k
959989
write(" xmlns%s=\"%s\"" % (
@@ -963,7 +993,7 @@ def _serialize_html(write, elem, qnames, namespaces, **kwargs):
963993
for k, v in items:
964994
if isinstance(k, QName):
965995
k = k.text
966-
k = qnames[k]
996+
k = attr_qnames[k]
967997
if v is None:
968998
write(" %s" % k) # empty attr
969999
else:
@@ -980,7 +1010,7 @@ def _serialize_html(write, elem, qnames, namespaces, **kwargs):
9801010
else:
9811011
write(_escape_cdata(text))
9821012
for e in elem:
983-
_serialize_html(write, e, qnames, None)
1013+
_serialize_html(write, e, qnames, attr_qnames, None)
9841014
if ltag not in HTML_EMPTY:
9851015
write("</" + tag + ">")
9861016
if elem.tail:
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
:mod:`xml.etree.ElementTree` no longer refuses to serialize attributes
2+
without a namespace when the *default_namespace* option is used.
3+
The default namespace declaration does not apply to attribute names,
4+
so an unqualified attribute name is written as is,
5+
and a qualified attribute name is always written with a prefix.

0 commit comments

Comments
 (0)