Skip to content

Commit 1351010

Browse files
pythongh-61290: Fix serializing attributes with the default_namespace option
The default namespace declaration does not apply to attribute names, so an unqualified attribute name can be written as is, and a qualified attribute name always needs a prefix, even if it is in the default namespace. They are now encoded separately from element names, and the namespaces table maps prefixes to URIs, because a URI can need both the default declaration and a prefixed one.
1 parent d636e3c commit 1351010

3 files changed

Lines changed: 116 additions & 29 deletions

File tree

Lib/test/test_xml_etree.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,52 @@ def test_tostring_default_namespace_original_no_namespace(self):
901901
with self.assertRaisesRegex(ValueError, EXPECTED_MSG):
902902
ET.tostring(elem, encoding='unicode', default_namespace='foobar')
903903

904+
def test_tostring_default_namespace_attributes(self):
905+
# gh-61290: the default namespace does not apply to attribute names
906+
elem = ET.XML('<body xmlns="http://effbot.org/ns" attr="value">'
907+
'<tag attr="value" /></body>')
908+
self.assertEqual(
909+
ET.tostring(elem, encoding='unicode',
910+
default_namespace='http://effbot.org/ns'),
911+
'<body xmlns="http://effbot.org/ns" attr="value">'
912+
'<tag attr="value" /></body>'
913+
)
914+
915+
def test_tostring_default_namespace_qualified_attributes(self):
916+
# a qualified attribute name always needs a prefix, even if it is
917+
# in the default namespace
918+
elem = ET.Element('{http://effbot.org/ns}body',
919+
{'{http://effbot.org/ns}attr': 'value'})
920+
self.assertEqual(
921+
ET.tostring(elem, encoding='unicode',
922+
default_namespace='http://effbot.org/ns'),
923+
'<body xmlns="http://effbot.org/ns" '
924+
'xmlns:ns1="http://effbot.org/ns" ns1:attr="value" />'
925+
)
926+
# an attribute in another namespace uses the prefix of that namespace
927+
elem = ET.Element('{http://effbot.org/ns}body',
928+
{'{foobar}attr': 'value', 'plain': 'value'})
929+
self.assertEqual(
930+
ET.tostring(elem, encoding='unicode',
931+
default_namespace='http://effbot.org/ns'),
932+
'<body xmlns="http://effbot.org/ns" xmlns:ns1="foobar" '
933+
'ns1:attr="value" plain="value" />'
934+
)
935+
936+
def test_tostring_default_namespace_attributes_round_trip(self):
937+
xml = ('<body xmlns="http://effbot.org/ns" xmlns:ns1="foobar" '
938+
'attr="1"><tag ns1:attr="2" /></body>')
939+
elem = ET.XML(xml)
940+
self.assertEqual(
941+
ET.tostring(elem, encoding='unicode',
942+
default_namespace='http://effbot.org/ns'),
943+
xml
944+
)
945+
self.assertEqual(
946+
[sorted(e.attrib.items()) for e in ET.XML(xml).iter()],
947+
[sorted(e.attrib.items()) for e in elem.iter()]
948+
)
949+
904950
def test_tostring_no_xml_declaration(self):
905951
elem = ET.XML('<body><tag/></body>')
906952
self.assertEqual(
@@ -970,6 +1016,14 @@ def test_tostring_xml_declaration_cases(self):
9701016
expected_retval
9711017
)
9721018

1019+
def test_tostring_default_namespace_attributes_html(self):
1020+
elem = ET.XML('<body xmlns="http://effbot.org/ns" attr="value" />')
1021+
self.assertEqual(
1022+
ET.tostring(elem, encoding='unicode', method='html',
1023+
default_namespace='http://effbot.org/ns'),
1024+
'<body xmlns="http://effbot.org/ns" attr="value"></body>'
1025+
)
1026+
9731027
def test_tostringlist_default_namespace(self):
9741028
elem = ET.XML('<body xmlns="http://effbot.org/ns"><tag/></body>')
9751029
self.assertEqual(

Lib/xml/etree/ElementTree.py

Lines changed: 57 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -743,9 +743,10 @@ def write(self, file_or_filename,
743743
if method == "text":
744744
_serialize_text(write, self._root)
745745
else:
746-
qnames, namespaces = _namespaces(self._root, default_namespace)
746+
qnames, attr_qnames, namespaces = _namespaces(
747+
self._root, default_namespace)
747748
serialize = _serialize[method]
748-
serialize(write, self._root, qnames, namespaces,
749+
serialize(write, self._root, qnames, attr_qnames, namespaces,
749750
short_empty_elements=short_empty_elements)
750751

751752
# --------------------------------------------------------------------
@@ -806,28 +807,57 @@ def _namespaces(elem, default_namespace=None):
806807

807808
# maps qnames to *encoded* prefix:local names
808809
qnames = {None: None}
810+
# The default namespace declaration does not apply to attribute names,
811+
# so they are encoded separately: an unqualified name is left as is,
812+
# and a qualified name always gets a prefix.
813+
attr_qnames = {None: None} if default_namespace else qnames
809814

810-
# maps uri:s to prefixes
815+
# maps prefixes to uri:s
811816
namespaces = {}
817+
# maps uri:s to prefixes, "" is the prefix of the default namespace
818+
prefixes = {}
819+
# maps uri:s to prefixes usable in attribute names
820+
attr_prefixes = {} if default_namespace else prefixes
812821
if default_namespace:
813-
namespaces[default_namespace] = ""
814-
815-
def add_qname(qname):
822+
namespaces[""] = default_namespace
823+
prefixes[default_namespace] = ""
824+
825+
def get_prefix(uri, isattr):
826+
# find or create the prefix for the namespace uri
827+
if isattr:
828+
prefix = attr_prefixes.get(uri)
829+
if prefix is None:
830+
# the default namespace is of no use for an attribute name
831+
prefix = prefixes.get(uri) or None
832+
else:
833+
prefix = prefixes.get(uri)
834+
if prefix is not None:
835+
return prefix
836+
prefix = _namespace_map.get(uri)
837+
if not prefix:
838+
prefix = "ns%d" % len(namespaces)
839+
if prefix != "xml":
840+
namespaces[prefix] = uri
841+
if isattr:
842+
attr_prefixes[uri] = prefix
843+
prefixes.setdefault(uri, prefix)
844+
return prefix
845+
846+
def add_qname(qname, isattr=False):
816847
# calculate serialized qname representation
817848
try:
818849
if qname[:1] == "{":
819850
uri, tag = qname[1:].rsplit("}", 1)
820-
prefix = namespaces.get(uri)
821-
if prefix is None:
822-
prefix = _namespace_map.get(uri)
823-
if prefix is None:
824-
prefix = "ns%d" % len(namespaces)
825-
if prefix != "xml":
826-
namespaces[uri] = prefix
851+
prefix = get_prefix(uri, isattr)
827852
if prefix:
828-
qnames[qname] = "%s:%s" % (prefix, tag)
853+
if isattr:
854+
attr_qnames[qname] = "%s:%s" % (prefix, tag)
855+
else:
856+
qnames[qname] = "%s:%s" % (prefix, tag)
829857
else:
830858
qnames[qname] = tag # default element
859+
elif isattr:
860+
attr_qnames[qname] = qname
831861
else:
832862
if default_namespace:
833863
# FIXME: can this be handled in XML 1.0?
@@ -853,16 +883,16 @@ def add_qname(qname):
853883
for key, value in elem.items():
854884
if isinstance(key, QName):
855885
key = key.text
856-
if key not in qnames:
857-
add_qname(key)
886+
if key not in attr_qnames:
887+
add_qname(key, isattr=True)
858888
if isinstance(value, QName) and value.text not in qnames:
859889
add_qname(value.text)
860890
text = elem.text
861891
if isinstance(text, QName) and text.text not in qnames:
862892
add_qname(text.text)
863-
return qnames, namespaces
893+
return qnames, attr_qnames, namespaces
864894

865-
def _serialize_xml(write, elem, qnames, namespaces,
895+
def _serialize_xml(write, elem, qnames, attr_qnames, namespaces,
866896
short_empty_elements, **kwargs):
867897
tag = elem.tag
868898
text = elem.text
@@ -876,15 +906,14 @@ def _serialize_xml(write, elem, qnames, namespaces,
876906
if text:
877907
write(_escape_cdata(text))
878908
for e in elem:
879-
_serialize_xml(write, e, qnames, None,
909+
_serialize_xml(write, e, qnames, attr_qnames, None,
880910
short_empty_elements=short_empty_elements)
881911
else:
882912
write("<" + tag)
883913
items = list(elem.items())
884914
if items or namespaces:
885915
if namespaces:
886-
for v, k in sorted(namespaces.items(),
887-
key=lambda x: x[1]): # sort on prefix
916+
for k, v in sorted(namespaces.items()): # sort on prefix
888917
if k:
889918
k = ":" + k
890919
write(" xmlns%s=\"%s\"" % (
@@ -898,13 +927,13 @@ def _serialize_xml(write, elem, qnames, namespaces,
898927
v = qnames[v.text]
899928
else:
900929
v = _escape_attrib(v)
901-
write(" %s=\"%s\"" % (qnames[k], v))
930+
write(" %s=\"%s\"" % (attr_qnames[k], v))
902931
if text or len(elem) or not short_empty_elements:
903932
write(">")
904933
if text:
905934
write(_escape_cdata(text))
906935
for e in elem:
907-
_serialize_xml(write, e, qnames, None,
936+
_serialize_xml(write, e, qnames, attr_qnames, None,
908937
short_empty_elements=short_empty_elements)
909938
write("</" + tag + ">")
910939
else:
@@ -919,7 +948,7 @@ def _serialize_xml(write, elem, qnames, namespaces,
919948
"img", "input", "isindex", "link", "meta", "param", "source",
920949
"track", "wbr", "plaintext"}
921950

922-
def _serialize_html(write, elem, qnames, namespaces, **kwargs):
951+
def _serialize_html(write, elem, qnames, attr_qnames, namespaces, **kwargs):
923952
tag = elem.tag
924953
text = elem.text
925954
if tag is Comment:
@@ -932,14 +961,13 @@ def _serialize_html(write, elem, qnames, namespaces, **kwargs):
932961
if text:
933962
write(_escape_cdata(text))
934963
for e in elem:
935-
_serialize_html(write, e, qnames, None)
964+
_serialize_html(write, e, qnames, attr_qnames, None)
936965
else:
937966
write("<" + tag)
938967
items = list(elem.items())
939968
if items or namespaces:
940969
if namespaces:
941-
for v, k in sorted(namespaces.items(),
942-
key=lambda x: x[1]): # sort on prefix
970+
for k, v in sorted(namespaces.items()): # sort on prefix
943971
if k:
944972
k = ":" + k
945973
write(" xmlns%s=\"%s\"" % (
@@ -949,7 +977,7 @@ def _serialize_html(write, elem, qnames, namespaces, **kwargs):
949977
for k, v in items:
950978
if isinstance(k, QName):
951979
k = k.text
952-
k = qnames[k]
980+
k = attr_qnames[k]
953981
if v is None:
954982
write(" %s" % k) # empty attr
955983
else:
@@ -966,7 +994,7 @@ def _serialize_html(write, elem, qnames, namespaces, **kwargs):
966994
else:
967995
write(_escape_cdata(text))
968996
for e in elem:
969-
_serialize_html(write, e, qnames, None)
997+
_serialize_html(write, e, qnames, attr_qnames, None)
970998
if ltag not in HTML_EMPTY:
971999
write("</" + tag + ">")
9721000
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)