Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions Lib/test/test_xml_etree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('<body xmlns="http://effbot.org/ns" attr="value">'
'<tag attr="value" /></body>')
self.assertEqual(
ET.tostring(elem, encoding='unicode',
default_namespace='http://effbot.org/ns'),
'<body xmlns="http://effbot.org/ns" attr="value">'
'<tag attr="value" /></body>'
)

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'),
'<body xmlns="http://effbot.org/ns" '
'xmlns:ns1="http://effbot.org/ns" ns1:attr="value" />'
)
# 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'),
'<body xmlns="http://effbot.org/ns" xmlns:ns1="foobar" '
'ns1:attr="value" plain="value" />'
)

def test_tostring_default_namespace_attributes_round_trip(self):
xml = ('<body xmlns="http://effbot.org/ns" xmlns:ns1="foobar" '
'attr="1"><tag ns1:attr="2" /></body>')
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'),
'<ns1:elem xmlns="otherdefault" xmlns:ns1="default" />'
)
# without the option the registered prefix is used
self.assertEqual(
ET.tostring(elem, encoding='unicode'),
'<elem xmlns="default" />'
)

def test_tostring_no_xml_declaration(self):
elem = ET.XML('<body><tag/></body>')
self.assertEqual(
Expand Down Expand Up @@ -970,6 +1034,14 @@ def test_tostring_xml_declaration_cases(self):
expected_retval
)

def test_tostring_default_namespace_attributes_html(self):
elem = ET.XML('<body xmlns="http://effbot.org/ns" attr="value" />')
self.assertEqual(
ET.tostring(elem, encoding='unicode', method='html',
default_namespace='http://effbot.org/ns'),
'<body xmlns="http://effbot.org/ns" attr="value"></body>'
)

def test_tostringlist_default_namespace(self):
elem = ET.XML('<body xmlns="http://effbot.org/ns"><tag/></body>')
self.assertEqual(
Expand Down
88 changes: 59 additions & 29 deletions Lib/xml/etree/ElementTree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

# --------------------------------------------------------------------
Expand Down Expand Up @@ -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?
Expand All @@ -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
Expand All @@ -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\"" % (
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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\"" % (
Expand All @@ -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:
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading