Skip to content

Commit 95317b0

Browse files
committed
pythongh-57587: Add nsmap to ElementTree.write()
This allows a custom prefix to uri mapping to be supplied to the serialisation process.
1 parent 4d7cbd4 commit 95317b0

3 files changed

Lines changed: 196 additions & 21 deletions

File tree

‎Doc/library/xml.etree.elementtree.rst‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -704,7 +704,7 @@ Functions
704704

705705
.. function:: tostring(element, encoding="us-ascii", method="xml", *, \
706706
xml_declaration=None, default_namespace=None, \
707-
short_empty_elements=True)
707+
short_empty_elements=True, nsmap=None)
708708

709709
Generates a string representation of an XML element, including all
710710
subelements. *element* is an :class:`Element` instance. *encoding* [1]_ is
@@ -725,10 +725,13 @@ Functions
725725
The :func:`tostring` function now preserves the attribute order
726726
specified by the user.
727727

728+
.. versionchanged:: 3.14
729+
Added the *nsmap* parameter.
730+
728731

729732
.. function:: tostringlist(element, encoding="us-ascii", method="xml", *, \
730733
xml_declaration=None, default_namespace=None, \
731-
short_empty_elements=True)
734+
short_empty_elements=True, nsmap=None)
732735

733736
Generates a string representation of an XML element, including all
734737
subelements. *element* is an :class:`Element` instance. *encoding* [1]_ is
@@ -752,6 +755,9 @@ Functions
752755
The :func:`tostringlist` function now preserves the attribute order
753756
specified by the user.
754757

758+
.. versionchanged:: 3.14
759+
Added the *nsmap* parameter.
760+
755761

756762
.. function:: XML(text, parser=None)
757763

@@ -1172,7 +1178,7 @@ ElementTree Objects
11721178

11731179
.. method:: write(file, encoding="us-ascii", xml_declaration=None, \
11741180
default_namespace=None, method="xml", *, \
1175-
short_empty_elements=True)
1181+
short_empty_elements=True, nsmap=None)
11761182
11771183
Writes the element tree to a file, as XML. *file* is a file name, or a
11781184
:term:`file object` opened for writing. *encoding* [1]_ is the output
@@ -1189,6 +1195,12 @@ ElementTree Objects
11891195
of elements that contain no content. If ``True`` (the default), they are
11901196
emitted as a single self-closed tag, otherwise they are emitted as a pair
11911197
of start/end tags.
1198+
The keyword-only *nsmap* parameter is a mapping of namespace prefixes to
1199+
namespace uris. These prefixes take precedence over namespaces registered
1200+
using :func:`register_namespace`. If provided, the *default_namespace*
1201+
argument takes precedence over a default prefix (``""``) supplied in
1202+
*nsmap*. All supplied entries will be declared in the root element
1203+
(unless replaced by the *default_namespace* argument).
11921204

11931205
The output is either a string (:class:`str`) or binary (:class:`bytes`).
11941206
This is controlled by the *encoding* argument. If *encoding* is
@@ -1204,6 +1216,9 @@ ElementTree Objects
12041216
The :meth:`write` method now preserves the attribute order specified
12051217
by the user.
12061218

1219+
.. versionchanged:: 3.14
1220+
Added the *nsmap* parameter.
1221+
12071222

12081223
This is the XML file that is going to be manipulated::
12091224

‎Lib/test/test_xml_etree.py‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,105 @@ def test_tostring_default_namespace_original_no_namespace(self):
10261026
with self.assertRaisesRegex(ValueError, EXPECTED_MSG):
10271027
ET.tostring(elem, encoding='unicode', default_namespace='foobar')
10281028

1029+
def test_tostring_nsmap(self):
1030+
elem = ET.XML('<body xmlns="http://effbot.org/ns" xmlns:foo="bar"><foo:tag/></body>')
1031+
self.assertEqual(
1032+
ET.tostring(elem, encoding='unicode'),
1033+
'<ns0:body xmlns:ns0="http://effbot.org/ns" xmlns:ns1="bar"><ns1:tag /></ns0:body>'
1034+
)
1035+
self.assertEqual(
1036+
ET.tostring(
1037+
elem,
1038+
encoding='unicode',
1039+
nsmap={
1040+
"": 'http://effbot.org/ns',
1041+
"foo": "bar",
1042+
"unused": "nothing",
1043+
},
1044+
),
1045+
'<body xmlns="http://effbot.org/ns" xmlns:foo="bar" xmlns:unused="nothing">'
1046+
'<foo:tag /></body>'
1047+
)
1048+
1049+
def test_tostring_nsmap_default_namespace(self):
1050+
elem = ET.XML('<body xmlns="http://effbot.org/ns"><tag/></body>')
1051+
self.assertEqual(
1052+
ET.tostring(elem, encoding='unicode'),
1053+
'<ns0:body xmlns:ns0="http://effbot.org/ns"><ns0:tag /></ns0:body>'
1054+
)
1055+
self.assertEqual(
1056+
ET.tostring(
1057+
elem,
1058+
encoding='unicode',
1059+
nsmap={"": 'http://effbot.org/ns'},
1060+
),
1061+
'<body xmlns="http://effbot.org/ns"><tag /></body>'
1062+
)
1063+
1064+
def test_tostring_nsmap_default_namespace_overrides(self):
1065+
elem = ET.XML('<body xmlns="http://effbot.org/ns"><tag/></body>')
1066+
self.assertEqual(
1067+
ET.tostring(
1068+
elem,
1069+
encoding='unicode',
1070+
default_namespace="other",
1071+
nsmap={"": 'http://effbot.org/ns'},
1072+
),
1073+
'<ns1:body xmlns="other" xmlns:ns1="http://effbot.org/ns">'
1074+
'<ns1:tag />'
1075+
'</ns1:body>'
1076+
)
1077+
self.assertEqual(
1078+
ET.tostring(
1079+
elem,
1080+
encoding='unicode',
1081+
default_namespace='http://effbot.org/ns',
1082+
nsmap={"": 'other'},
1083+
),
1084+
'<body xmlns="http://effbot.org/ns"><tag /></body>'
1085+
)
1086+
1087+
def test_tostring_nsmap_default_namespace_attr(self):
1088+
reg_name = "gh57587"
1089+
namespace = "ns_gh57587"
1090+
elem = ET.XML(
1091+
f'<body xmlns="{namespace}" xmlns:foo="{namespace}" foo:status="good">'
1092+
'<tag/></body>'
1093+
)
1094+
self.assertEqual(
1095+
ET.tostring(elem, encoding='unicode'),
1096+
f'<ns0:body xmlns:ns0="{namespace}" ns0:status="good"><ns0:tag /></ns0:body>'
1097+
)
1098+
ET.register_namespace(reg_name, namespace)
1099+
self.assertEqual(
1100+
ET.tostring(
1101+
elem,
1102+
encoding='unicode',
1103+
nsmap={
1104+
"": namespace,
1105+
"foo": namespace,
1106+
},
1107+
),
1108+
f'<body xmlns="{namespace}" xmlns:foo="{namespace}" foo:status="good">'
1109+
'<tag /></body>'
1110+
)
1111+
# default attr gets name from global registered namespaces
1112+
self.assertEqual(
1113+
ET.tostring(
1114+
elem,
1115+
encoding='unicode',
1116+
nsmap={"": namespace},
1117+
),
1118+
f'<body xmlns="{namespace}" xmlns:{reg_name}="{namespace}" {reg_name}:status="good">'
1119+
'<tag /></body>'
1120+
)
1121+
1122+
def test_tostring_nsmap_default_namespace_original_no_namespace(self):
1123+
elem = ET.XML('<body><tag/></body>')
1124+
EXPECTED_MSG = '^cannot use non-qualified names with default_namespace option$'
1125+
with self.assertRaisesRegex(ValueError, EXPECTED_MSG):
1126+
ET.tostring(elem, encoding='unicode', nsmap={"": "foobar"})
1127+
10291128
def test_tostring_no_xml_declaration(self):
10301129
elem = ET.XML('<body><tag/></body>')
10311130
self.assertEqual(
@@ -1102,6 +1201,23 @@ def test_tostringlist_default_namespace(self):
11021201
'<body xmlns="http://effbot.org/ns"><tag /></body>'
11031202
)
11041203

1204+
def test_tostringlist_nsmap_default_namespace(self):
1205+
elem = ET.XML('<body xmlns="http://effbot.org/ns"><tag/></body>')
1206+
self.assertEqual(
1207+
''.join(ET.tostringlist(elem, encoding='unicode')),
1208+
'<ns0:body xmlns:ns0="http://effbot.org/ns"><ns0:tag /></ns0:body>'
1209+
)
1210+
self.assertEqual(
1211+
"".join(
1212+
ET.tostringlist(
1213+
elem,
1214+
encoding='unicode',
1215+
nsmap={"": 'http://effbot.org/ns'},
1216+
)
1217+
),
1218+
'<body xmlns="http://effbot.org/ns"><tag /></body>'
1219+
)
1220+
11051221
def test_tostringlist_xml_declaration(self):
11061222
elem = ET.XML('<body><tag/></body>')
11071223
self.assertEqual(

‎Lib/xml/etree/ElementTree.py‎

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -681,7 +681,8 @@ def write(self, file_or_filename,
681681
xml_declaration=None,
682682
default_namespace=None,
683683
method=None, *,
684-
short_empty_elements=True):
684+
short_empty_elements=True,
685+
nsmap=None):
685686
"""Write element tree to a file as XML.
686687
687688
Arguments:
@@ -696,7 +697,7 @@ def write(self, file_or_filename,
696697
697698
*default_namespace* -- sets the default XML namespace (for "xmlns").
698699
This option takes precedence over a default
699-
namespace registered using
700+
namespace in nsmap or registered using
700701
register_namespace().
701702
702703
*method* -- either "xml" (default), "html, "text", or "c14n"
@@ -707,6 +708,14 @@ def write(self, file_or_filename,
707708
tag, otherwise they are emitted as a pair
708709
of start/end tags
709710
711+
*nsmap* -- a mapping of namespace prefixes to namespace uris. These
712+
prefixes take precedence over namespaces registered using
713+
register_namespace(). If provided, the default_namespace
714+
argument takes precedence over a default prefix ("")
715+
supplied in nsmap. All supplied entries will be declared
716+
in the root element (unless the "" prefix has been
717+
replaced by the default_namespace argument).
718+
710719
"""
711720
if not method:
712721
method = "xml"
@@ -727,7 +736,11 @@ def write(self, file_or_filename,
727736
if method == "text":
728737
_serialize_text(write, self._root)
729738
else:
730-
qnames, nsmap = _namespaces(self._root, default_namespace)
739+
qnames, nsmap = _namespaces(
740+
self._root,
741+
default_namespace,
742+
nsmap,
743+
)
731744
serialize = _serialize[method]
732745
serialize(write, self._root, qnames, nsmap,
733746
short_empty_elements=short_empty_elements)
@@ -802,6 +815,25 @@ def _make_new_ns_prefix(nsmap_scope, global_prefixes):
802815
i += 1
803816

804817

818+
def _find_default_namespace_attr_prefix(
819+
default_namespace,
820+
nsmap,
821+
global_prefixes,
822+
):
823+
# Search the provided nsmap for any prefixes for this uri that aren't the
824+
# default namespace ""
825+
for prefix, uri in nsmap.items():
826+
if uri == default_namespace and prefix != "":
827+
return prefix
828+
829+
# _namespace_map is a 1:1 mapping of uri -> prefix
830+
prefix = _namespace_map.get(default_namespace)
831+
if prefix and prefix not in nsmap:
832+
return prefix
833+
834+
return _make_new_ns_prefix(nsmap, global_prefixes)
835+
836+
805837
def _qnames_iter(elem):
806838
"""Iterate through all the qualified names in elem"""
807839
seen_el_qnames = set()
@@ -839,17 +871,25 @@ def _qnames_iter(elem):
839871
yield text.text, False
840872

841873

842-
def _namespaces(elem, default_namespace=None):
874+
def _namespaces(elem, default_namespace=None, nsmap=None):
843875
# identify namespaces used in this tree
844876

845877
qnames = {None: (None, None)}
846878

847-
nsmap = {}
848-
uri_to_prefix = {}
849-
default_namespace_attr_prefix = None
850-
if default_namespace:
879+
if nsmap is None:
880+
nsmap = {}
881+
else:
882+
nsmap = nsmap.copy()
883+
884+
if default_namespace is not None:
851885
nsmap[""] = default_namespace
852-
uri_to_prefix[default_namespace] = ""
886+
887+
# Multiple prefixes may be present for a single uri. This will select the
888+
# last prefix found in nsmap for a given uri.
889+
uri_to_prefix = {uri: prefix for prefix, uri in nsmap.items()}
890+
if "" in nsmap:
891+
# but we make sure the default prefix always take precedence
892+
uri_to_prefix[nsmap[""]] = ""
853893

854894
global_prefixes = set(_namespace_map.values())
855895
has_unqual_el = False
@@ -871,10 +911,11 @@ def _namespaces(elem, default_namespace=None):
871911
if not is_el and not prefix and not default_namespace_attr_prefix:
872912
# Find the alternative prefix to use with non-element
873913
# names
874-
default_namespace_attr_prefix = _namespace_map.get(uri_and_name[0])
875-
if not default_namespace_attr_prefix:
876-
# if default_namespace_attr_prefix == "" or None
877-
default_namespace_attr_prefix = _make_new_ns_prefix(nsmap, global_prefixes)
914+
default_namespace_attr_prefix = _find_default_namespace_attr_prefix(
915+
uri_and_name[0],
916+
nsmap,
917+
global_prefixes,
918+
)
878919
nsmap[default_namespace_attr_prefix] = uri_and_name[0]
879920
# Don't add this uri to prefix mapping as it might override
880921
# the uri -> "" default mapping. We'll fix this up at the
@@ -1129,7 +1170,7 @@ def _escape_attrib_html(text):
11291170

11301171
def tostring(element, encoding=None, method=None, *,
11311172
xml_declaration=None, default_namespace=None,
1132-
short_empty_elements=True):
1173+
short_empty_elements=True, nsmap=None):
11331174
"""Generate string representation of XML element.
11341175
11351176
All subelements are included. If encoding is "unicode", a string
@@ -1138,7 +1179,8 @@ def tostring(element, encoding=None, method=None, *,
11381179
*element* is an Element instance, *encoding* is an optional output
11391180
encoding defaulting to US-ASCII, *method* is an optional output which can
11401181
be one of "xml" (default), "html", "text" or "c14n", *default_namespace*
1141-
sets the default XML namespace (for "xmlns").
1182+
sets the default XML namespace (for "xmlns"). *nsmap* is a mapping of
1183+
prefixes to namespaces.
11421184
11431185
Returns an (optionally) encoded string containing the XML data.
11441186
@@ -1148,7 +1190,8 @@ def tostring(element, encoding=None, method=None, *,
11481190
xml_declaration=xml_declaration,
11491191
default_namespace=default_namespace,
11501192
method=method,
1151-
short_empty_elements=short_empty_elements)
1193+
short_empty_elements=short_empty_elements,
1194+
nsmap=nsmap)
11521195
return stream.getvalue()
11531196

11541197
class _ListDataStream(io.BufferedIOBase):
@@ -1170,14 +1213,15 @@ def tell(self):
11701213

11711214
def tostringlist(element, encoding=None, method=None, *,
11721215
xml_declaration=None, default_namespace=None,
1173-
short_empty_elements=True):
1216+
short_empty_elements=True, nsmap=None):
11741217
lst = []
11751218
stream = _ListDataStream(lst)
11761219
ElementTree(element).write(stream, encoding,
11771220
xml_declaration=xml_declaration,
11781221
default_namespace=default_namespace,
11791222
method=method,
1180-
short_empty_elements=short_empty_elements)
1223+
short_empty_elements=short_empty_elements,
1224+
nsmap=nsmap)
11811225
return lst
11821226

11831227

0 commit comments

Comments
 (0)