Skip to content

Commit 95ceb77

Browse files
committed
pythongh-61290: Fix ElementTree's serialization of attr names when default_namespace is provided
1 parent 862ec38 commit 95ceb77

3 files changed

Lines changed: 162 additions & 58 deletions

File tree

‎Lib/test/test_xml_etree.py‎

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ def assertEqualElements(self, alice, bob):
196196
# --------------------------------------------------------------------
197197
# element tree tests
198198

199-
class ElementTreeTest(unittest.TestCase):
199+
class ElementTreeTest(ElementTestCase, unittest.TestCase):
200200

201201
def serialize_check(self, elem, expected):
202202
self.assertEqual(serialize(elem), expected)
@@ -1337,6 +1337,79 @@ def test_qname(self):
13371337
self.assertNotEqual(q1, 'ns:tag')
13381338
self.assertEqual(q1, '{ns}tag')
13391339

1340+
def test_namespace_attribs(self):
1341+
# Unprefixed attributes are unqualified even if a default
1342+
# namespace is in effect. (This is a little unclear in some
1343+
# versions of the XML TR but is clarified in errata and other
1344+
# versions.) See bugs.python.org issue 17088.
1345+
#
1346+
# The reasoning behind this, alluded to in the spec, is that
1347+
# attribute meanings already depend on the element they're
1348+
# attached to; attributes have always lived in per-element
1349+
# namespaces even before explicit XML namespaces were
1350+
# introduced. For that reason qualified attribute names are
1351+
# only really needed when one XML module defines attributes
1352+
# that can be placed on elements defined in a different module
1353+
# (such as happens with XLINK or, for that matter, the XML
1354+
# namespace spec itself).
1355+
e = ET.XML(
1356+
'<pf:elt xmlns:pf="space1" xmlns:pf2="space2" foo="value">'
1357+
'<pf:foo foo="value2" pf2:foo="value3"/>'
1358+
'<pf2:foo foo="value4" pf:foo="value5" pf2:foo="value6"/>'
1359+
'<foo foo="value7" pf:foo="value8"/>'
1360+
'</pf:elt>')
1361+
self.assertEqual(e.tag, '{space1}elt')
1362+
self.assertEqual(e.get('foo'), 'value')
1363+
self.assertIsNone(e.get('{space1}foo'))
1364+
self.assertIsNone(e.get('{space2}foo'))
1365+
self.assertEqual(e[0].tag, '{space1}foo')
1366+
self.assertEqual(e[0].attrib, { 'foo': 'value2',
1367+
'{space2}foo': 'value3' })
1368+
self.assertEqual(e[1].tag, '{space2}foo')
1369+
self.assertEqual(e[1].attrib, { 'foo': 'value4',
1370+
'{space1}foo': 'value5',
1371+
'{space2}foo': 'value6' })
1372+
self.assertEqual(e[2].tag, 'foo')
1373+
self.assertEqual(e[2].attrib, { 'foo': 'value7',
1374+
'{space1}foo': 'value8' })
1375+
1376+
serialized1 = (
1377+
'<ns0:elt xmlns:ns0="space1" xmlns:ns1="space2" foo="value">'
1378+
'<ns0:foo foo="value2" ns1:foo="value3" />'
1379+
'<ns1:foo foo="value4" ns0:foo="value5" ns1:foo="value6" />'
1380+
'<foo foo="value7" ns0:foo="value8" />'
1381+
'</ns0:elt>')
1382+
self.assertEqual(serialize(e), serialized1)
1383+
self.assertEqualElements(e, ET.XML(serialized1))
1384+
1385+
# Test writing with a default namespace.
1386+
with self.assertRaisesRegex(ValueError,
1387+
'cannot use non-qualified name.* with default_namespace option'):
1388+
serialize(e, default_namespace="space1")
1389+
1390+
# Remove the unqualified element from the tree so we can test
1391+
# further.
1392+
del e[2]
1393+
1394+
# Serialization can require a namespace prefix to be declared for
1395+
# space1 even if no elements use that prefix, in order to
1396+
# write an attribute name in that namespace.
1397+
serialized2 = (
1398+
'<ns1:elt xmlns="space2" xmlns:ns1="space1" xmlns:ns2="space2" foo="value">'
1399+
'<ns1:foo foo="value2" ns2:foo="value3" />'
1400+
'<foo foo="value4" ns1:foo="value5" ns2:foo="value6" />'
1401+
'</ns1:elt>')
1402+
self.assertEqual(serialize(e, default_namespace="space2"), serialized2)
1403+
self.assertEqualElements(e, ET.XML(serialized2))
1404+
1405+
serialized3 = (
1406+
'<elt xmlns="space1" xmlns:ns1="space2" xmlns:ns2="space1" foo="value">'
1407+
'<foo foo="value2" ns1:foo="value3" />'
1408+
'<ns1:foo foo="value4" ns2:foo="value5" ns1:foo="value6" />'
1409+
'</elt>')
1410+
self.assertEqual(serialize(e, default_namespace="space1"), serialized3)
1411+
self.assertEqualElements(e, ET.XML(serialized3))
1412+
13401413
def test_doctype_public(self):
13411414
# Test PUBLIC doctype.
13421415

‎Lib/xml/etree/ElementTree.py‎

Lines changed: 83 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -724,9 +724,9 @@ def write(self, file_or_filename,
724724
if method == "text":
725725
_serialize_text(write, self._root)
726726
else:
727-
qnames, namespaces = _namespaces(self._root, default_namespace)
727+
qnames, nsmap = _namespaces(self._root, default_namespace)
728728
serialize = _serialize[method]
729-
serialize(write, self._root, qnames, namespaces,
729+
serialize(write, self._root, qnames, nsmap,
730730
short_empty_elements=short_empty_elements)
731731

732732
def write_c14n(self, file):
@@ -787,84 +787,113 @@ def _get_writer(file_or_filename, encoding):
787787
yield file.write, encoding
788788

789789

790-
def _make_new_ns_prefix(nsmap_scope, seen_prefixes):
790+
def _make_new_ns_prefix(nsmap_scope, global_prefixes):
791791
i = len(nsmap_scope)
792792
while True:
793793
prefix = f"ns{i}"
794-
if prefix not in seen_prefixes:
794+
if (
795+
prefix not in nsmap_scope
796+
and prefix not in global_prefixes
797+
):
795798
return prefix
796799
i += 1
797800

798801

799802
def _qnames_iter(elem):
800803
"""Iterate through all the qualified names in elem"""
801-
seen_qnames = set()
804+
seen_el_qnames = set()
805+
seen_other_qnames = set()
802806
for elem in elem.iter():
803807
tag = elem.tag
804808
if isinstance(tag, str):
805-
if tag not in seen_qnames:
806-
seen_qnames.add(tag)
807-
yield tag
809+
if tag not in seen_el_qnames:
810+
seen_el_qnames.add(tag)
811+
yield tag, True
808812
elif isinstance(tag, QName):
809813
tag = tag.text
810-
if tag not in seen_qnames:
811-
seen_qnames.add(tag)
812-
yield tag
814+
if tag not in seen_el_qnames:
815+
seen_el_qnames.add(tag)
816+
yield tag, True
813817
elif tag is not None and tag is not Comment and tag is not PI:
814818
_raise_serialization_error(tag)
815819

816820
for key, value in elem.items():
817821
if isinstance(key, QName):
818822
key = key.text
819-
if key not in seen_qnames:
820-
seen_qnames.add(key)
821-
yield key
823+
if key not in seen_other_qnames:
824+
seen_other_qnames.add(key)
825+
yield key, False
822826

823827
if isinstance(value, QName):
824-
if value.text not in seen_qnames:
825-
seen_qnames.add(value.text)
826-
yield value.text
828+
if value.text not in seen_other_qnames:
829+
seen_other_qnames.add(value.text)
830+
yield value.text, False
827831

828832
text = elem.text
829833
if isinstance(text, QName):
830-
if text.text not in seen_qnames:
831-
seen_qnames.add(text.text)
832-
yield text.text
834+
if text.text not in seen_other_qnames:
835+
seen_other_qnames.add(text.text)
836+
yield text.text, False
833837

834838

835839
def _namespaces(elem, default_namespace=None):
836840
# identify namespaces used in this tree
837841

838-
# maps qnames to *encoded* prefix:local names
839-
qnames = {None: None}
842+
qnames = {None: (None, None)}
840843

841-
# maps uri:s to prefixes
842-
namespaces = {}
844+
nsmap = {}
845+
uri_to_prefix = {}
846+
default_namespace_attr_prefix = None
843847
if default_namespace:
844-
namespaces[default_namespace] = ""
848+
nsmap[""] = default_namespace
849+
uri_to_prefix[default_namespace] = ""
845850

846-
seen_prefixes = set(_namespace_map.values())
851+
global_prefixes = set(_namespace_map.values())
847852
has_unqual_el = False
848-
for qname in _qnames_iter(elem):
853+
default_namespace_attr_prefix = None
854+
for qname, is_el in _qnames_iter(elem):
849855
try:
850856
if qname[:1] == "{":
851857
uri_and_name = qname[1:].rsplit("}", 1)
852858

853-
prefix = namespaces.get(uri_and_name[0])
859+
prefix = uri_to_prefix.get(uri_and_name[0])
854860
if prefix is None:
855861
prefix = _namespace_map.get(uri_and_name[0])
856862
if prefix is None:
857-
prefix = _make_new_ns_prefix(namespaces, seen_prefixes)
858-
seen_prefixes.add(prefix)
859-
namespaces[uri_and_name[0]] = prefix
863+
prefix = _make_new_ns_prefix(nsmap, global_prefixes)
864+
if prefix or is_el:
865+
nsmap[prefix] = uri_and_name[0]
866+
uri_to_prefix[uri_and_name[0]] = prefix
867+
868+
if not is_el and not prefix and not default_namespace_attr_prefix:
869+
# Find the alternative prefix to use with non-element
870+
# names
871+
default_namespace_attr_prefix = _namespace_map.get(uri_and_name[0])
872+
if not default_namespace_attr_prefix:
873+
# if default_namespace_attr_prefix == "" or None
874+
default_namespace_attr_prefix = _make_new_ns_prefix(nsmap, global_prefixes)
875+
nsmap[default_namespace_attr_prefix] = uri_and_name[0]
876+
# Don't add this uri to prefix mapping as it might override
877+
# the uri -> "" default mapping. We'll fix this up at the
878+
# end of the fn.
879+
# uri_to_prefix[uri_and_name[0]] = default_namespace_attr_prefix
860880

861881
if prefix:
862-
qnames[qname] = f"{prefix}:{uri_and_name[1]}"
882+
name = f"{prefix}:{uri_and_name[1]}"
883+
qnames[qname] = (name, name)
863884
else:
864-
qnames[qname] = uri_and_name[1] # default element
885+
# default_namespace_attr_prefix may not be defined yet but
886+
# this bad entry will be replaced if an attribute that
887+
# requires default_namespace_attr_prefix is found later.
888+
qnames[qname] = (
889+
uri_and_name[1], # default element
890+
f"{default_namespace_attr_prefix}:{uri_and_name[1]}", # non-element qname
891+
)
865892
else:
866-
qnames[qname] = qname
867-
has_unqual_el = True
893+
# Unqualified qname
894+
qnames[qname] = (qname, qname)
895+
if is_el:
896+
has_unqual_el = True
868897
except TypeError:
869898
_raise_serialization_error(qname)
870899

@@ -874,14 +903,13 @@ def _namespaces(elem, default_namespace=None):
874903
"cannot use non-qualified names with default_namespace option"
875904
)
876905

877-
# This namespace doesn't need to be declared but may be used to prefix
878-
# names so let's remove it if it has been used
879-
if "http://www.w3.org/XML/1998/namespace" in namespaces:
880-
del namespaces["http://www.w3.org/XML/1998/namespace"]
881-
return qnames, namespaces
882-
906+
# The xml prefix doesn't need to be declared but may have been used to
907+
# prefix names. Let's remove it if it has been used
908+
if "xml" in nsmap:
909+
del nsmap["xml"]
910+
return qnames, nsmap
883911

884-
def _serialize_xml(write, elem, qnames, namespaces,
912+
def _serialize_xml(write, elem, qnames, nsmap,
885913
short_empty_elements, **kwargs):
886914
tag = elem.tag
887915
text = elem.text
@@ -890,7 +918,7 @@ def _serialize_xml(write, elem, qnames, namespaces,
890918
elif tag is ProcessingInstruction:
891919
write("<?%s?>" % text)
892920
else:
893-
tag = qnames[tag]
921+
tag = qnames[tag][0]
894922
if tag is None:
895923
if text:
896924
write(_escape_cdata(text))
@@ -900,10 +928,9 @@ def _serialize_xml(write, elem, qnames, namespaces,
900928
else:
901929
write("<" + tag)
902930
items = list(elem.items())
903-
if items or namespaces:
904-
if namespaces:
905-
for v, k in sorted(namespaces.items(),
906-
key=lambda x: x[1]): # sort on prefix
931+
if items or nsmap:
932+
if nsmap:
933+
for k, v in sorted(nsmap.items()):
907934
if k:
908935
k = ":" + k
909936
write(" xmlns%s=\"%s\"" % (
@@ -914,10 +941,10 @@ def _serialize_xml(write, elem, qnames, namespaces,
914941
if isinstance(k, QName):
915942
k = k.text
916943
if isinstance(v, QName):
917-
v = qnames[v.text]
944+
v = qnames[v.text][1]
918945
else:
919946
v = _escape_attrib(v)
920-
write(" %s=\"%s\"" % (qnames[k], v))
947+
write(" %s=\"%s\"" % (qnames[k][1], v))
921948
if text or len(elem) or not short_empty_elements:
922949
write(">")
923950
if text:
@@ -935,15 +962,15 @@ def _serialize_xml(write, elem, qnames, namespaces,
935962
"img", "input", "isindex", "link", "meta", "param", "source",
936963
"track", "wbr"}
937964

938-
def _serialize_html(write, elem, qnames, namespaces, **kwargs):
965+
def _serialize_html(write, elem, qnames, nsmap, **kwargs):
939966
tag = elem.tag
940967
text = elem.text
941968
if tag is Comment:
942969
write("<!--%s-->" % _escape_cdata(text))
943970
elif tag is ProcessingInstruction:
944971
write("<?%s?>" % _escape_cdata(text))
945972
else:
946-
tag = qnames[tag]
973+
tag = qnames[tag][0]
947974
if tag is None:
948975
if text:
949976
write(_escape_cdata(text))
@@ -952,10 +979,9 @@ def _serialize_html(write, elem, qnames, namespaces, **kwargs):
952979
else:
953980
write("<" + tag)
954981
items = list(elem.items())
955-
if items or namespaces:
956-
if namespaces:
957-
for v, k in sorted(namespaces.items(),
958-
key=lambda x: x[1]): # sort on prefix
982+
if items or nsmap:
983+
if nsmap:
984+
for k, v in sorted(nsmap.items()):
959985
if k:
960986
k = ":" + k
961987
write(" xmlns%s=\"%s\"" % (
@@ -966,11 +992,11 @@ def _serialize_html(write, elem, qnames, namespaces, **kwargs):
966992
if isinstance(k, QName):
967993
k = k.text
968994
if isinstance(v, QName):
969-
v = qnames[v.text]
995+
v = qnames[v.text][1]
970996
else:
971997
v = _escape_attrib_html(v)
972998
# FIXME: handle boolean attributes
973-
write(" %s=\"%s\"" % (qnames[k], v))
999+
write(" %s=\"%s\"" % (qnames[k][1], v))
9741000
write(">")
9751001
ltag = tag.lower()
9761002
if text:
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
When the ``default_namespace`` keyword is provided to
2+
:func:`xml.etree.ElementTree.write`, it no longer raises errors about
3+
unqualified attribute names and ensures a prefix is retained when an
4+
attribute is qualified by the ``default_namespace``. This is inline with the
5+
xml spec https://www.w3.org/TR/xml-names/#defaulting

0 commit comments

Comments
 (0)