From e3010a590a0664aa1aae175df9f57ec996bc7410 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Mon, 14 Sep 2026 22:33:54 +0300 Subject: [PATCH 1/3] gh-157518: Validate the prefix in xml.etree.ElementTree.register_namespace() ValueError is now raised for an invalid prefix, for the reserved xmlns prefix, and for the xml prefix bound to other namespace or other prefix bound to the XML namespace, as the documentation always promised. Only the reserved nsN prefixes were rejected before. --- Doc/library/xml.etree.elementtree.rst | 6 ++++ Lib/test/test_xml_etree.py | 30 +++++++++++++++++++ Lib/xml/etree/ElementTree.py | 27 +++++++++++++++-- ...-09-14-11-00-00.gh-issue-157518.Rk8mTz.rst | 4 +++ 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-14-11-00-00.gh-issue-157518.Rk8mTz.rst diff --git a/Doc/library/xml.etree.elementtree.rst b/Doc/library/xml.etree.elementtree.rst index a61fb05bf99d87..f03f84d138e255 100644 --- a/Doc/library/xml.etree.elementtree.rst +++ b/Doc/library/xml.etree.elementtree.rst @@ -739,9 +739,15 @@ Functions *prefix* is a namespace prefix. *uri* is a namespace uri. Tags and attributes in this namespace will be serialized with the given prefix, if at all possible. + :exc:`ValueError` is raised if *prefix* is not a valid prefix, + is reserved (``xmlns`` and ``ns`` followed by digits), + or if *prefix* is ``xml`` and *uri* is not the XML namespace or vice versa. .. versionadded:: 3.2 + .. versionchanged:: next + Invalid and reserved prefixes are now rejected. + .. function:: SubElement(parent, tag, /, attrib={}, **extra) diff --git a/Lib/test/test_xml_etree.py b/Lib/test/test_xml_etree.py index 899947c1f8e0d7..6f06c2c8f6a351 100644 --- a/Lib/test/test_xml_etree.py +++ b/Lib/test/test_xml_etree.py @@ -2848,6 +2848,36 @@ def test_bug_200709_register_namespace(self): self.assertEqual(ET.tostring(e), b'') + def test_register_namespace_invalid(self): + # gh-157518 + xml_ns = 'http://www.w3.org/XML/1998/namespace' + xmlns_ns = 'http://www.w3.org/2000/xmlns/' + nsmap = ET.register_namespace._namespace_map + saved = dict(nsmap) + for prefix, uri in [ + ('ns0', 'u'), ('ns12', 'u'), + ('xml', 'u'), ('foo', xml_ns), + ('xmlns', 'u'), ('foo', xmlns_ns), + ('a:b', 'u'), ('1', 'u'), ('a b', 'u'), ('a\xa0b', 'u'), + ]: + with self.subTest(prefix=prefix, uri=uri): + with self.assertRaises(ValueError): + ET.register_namespace(prefix, uri) + # the registry is not changed + self.assertEqual(nsmap, saved) + for prefix, uri in [(1, 'u'), (b'a', 'u'), ('a', 1), ('a', None)]: + with self.subTest(prefix=prefix, uri=uri): + with self.assertRaises(TypeError): + ET.register_namespace(prefix, uri) + # the xml prefix can be registered for its namespace + ET.register_namespace('xml', xml_ns) + self.assertEqual(ET.register_namespace._namespace_map[xml_ns], 'xml') + # non-ASCII names are valid + self.addCleanup(ET.register_namespace._namespace_map.pop, 'u', None) + ET.register_namespace('\xe9', 'u') + self.assertEqual(ET.tostring(ET.Element('{u}a'), encoding='unicode'), + '<\xe9:a xmlns:\xe9="u" />') + def test_bug_200709_element_comment(self): # Not sure if this can be fixed, really (since the serializer needs # ET.Comment, not cET.comment). diff --git a/Lib/xml/etree/ElementTree.py b/Lib/xml/etree/ElementTree.py index 0cd04ead801600..10200671634013 100644 --- a/Lib/xml/etree/ElementTree.py +++ b/Lib/xml/etree/ElementTree.py @@ -98,6 +98,7 @@ import contextlib import weakref +from .. import is_valid_name from . import ElementPath @@ -1029,6 +1030,29 @@ def _serialize_text(write, elem): } +_XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace" +_XMLNS_NAMESPACE = "http://www.w3.org/2000/xmlns/" + +def _check_prefix(prefix, uri): + # Check a namespace prefix and the namespace URI bound to it + # (see Namespaces in XML 1.0, 3 and 4). The empty prefix is allowed. + if not isinstance(prefix, str) or not isinstance(uri, str): + raise TypeError("namespace prefix and URI must be strings") + if re.match(r"ns\d+$", prefix): + raise ValueError("Prefix format reserved for internal use") + if prefix == "xml": + if uri != _XML_NAMESPACE: + raise ValueError("the 'xml' prefix can only be bound to " + "the XML namespace") + elif uri == _XML_NAMESPACE: + raise ValueError("the XML namespace can only be bound to " + "the 'xml' prefix") + elif prefix == "xmlns" or uri == _XMLNS_NAMESPACE: + raise ValueError("the 'xmlns' prefix cannot be bound") + elif prefix and (not is_valid_name(prefix) or ':' in prefix): + raise ValueError("invalid namespace prefix %r" % (prefix,)) + + def register_namespace(prefix, uri): """Register a namespace prefix. @@ -1041,8 +1065,7 @@ def register_namespace(prefix, uri): ValueError is raised if prefix is reserved or is invalid. """ - if re.match(r"ns\d+$", prefix): - raise ValueError("Prefix format reserved for internal use") + _check_prefix(prefix, uri) for k, v in list(_namespace_map.items()): if k == uri or v == prefix: del _namespace_map[k] diff --git a/Misc/NEWS.d/next/Library/2026-09-14-11-00-00.gh-issue-157518.Rk8mTz.rst b/Misc/NEWS.d/next/Library/2026-09-14-11-00-00.gh-issue-157518.Rk8mTz.rst new file mode 100644 index 00000000000000..6c3512e7fefbac --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-14-11-00-00.gh-issue-157518.Rk8mTz.rst @@ -0,0 +1,4 @@ +:func:`xml.etree.ElementTree.register_namespace` now raises +:exc:`ValueError` for an invalid prefix, for the reserved ``xmlns`` prefix, +and for the ``xml`` prefix or the XML namespace bound to each other's wrong +counterpart, instead of accepting them and serializing invalid XML. From 8fd4fc7c77fbd5703005cc9599140eec54d14d12 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Mon, 14 Sep 2026 22:45:01 +0300 Subject: [PATCH 2/3] Simplify the documentation of the restrictions --- Doc/library/xml.etree.elementtree.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Doc/library/xml.etree.elementtree.rst b/Doc/library/xml.etree.elementtree.rst index f03f84d138e255..8bd7b4ada3a592 100644 --- a/Doc/library/xml.etree.elementtree.rst +++ b/Doc/library/xml.etree.elementtree.rst @@ -739,9 +739,8 @@ Functions *prefix* is a namespace prefix. *uri* is a namespace uri. Tags and attributes in this namespace will be serialized with the given prefix, if at all possible. - :exc:`ValueError` is raised if *prefix* is not a valid prefix, - is reserved (``xmlns`` and ``ns`` followed by digits), - or if *prefix* is ``xml`` and *uri* is not the XML namespace or vice versa. + :exc:`ValueError` is raised if *prefix* is invalid or reserved + (``ns`` followed by digits is reserved for the serializer). .. versionadded:: 3.2 From 0b69d6fe6c908641b1d43d3ff278e537e33171ec Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Mon, 14 Sep 2026 22:00:59 +0300 Subject: [PATCH 3/3] gh-57587: Add the namespaces parameter to the ElementTree serializer tostring(), tostringlist() and ElementTree.write() accept a mapping from namespace prefixes to URIs which chooses the prefixes for this serialization, instead of the global registry of register_namespace(). Only the namespaces used in the tree are declared; the empty prefix sets the default namespace; a registered prefix which the mapping reserves for another namespace is not used. --- Doc/library/xml.etree.elementtree.rst | 38 ++++++--- Doc/whatsnew/3.16.rst | 7 ++ Lib/test/test_xml_etree.py | 78 +++++++++++++++++++ Lib/xml/etree/ElementTree.py | 49 +++++++++--- ...6-09-14-10-00-00.gh-issue-57587.Xn4pQ2.rst | 5 ++ 5 files changed, 158 insertions(+), 19 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-14-10-00-00.gh-issue-57587.Xn4pQ2.rst diff --git a/Doc/library/xml.etree.elementtree.rst b/Doc/library/xml.etree.elementtree.rst index 8bd7b4ada3a592..24efcef66337bb 100644 --- a/Doc/library/xml.etree.elementtree.rst +++ b/Doc/library/xml.etree.elementtree.rst @@ -742,6 +742,11 @@ Functions :exc:`ValueError` is raised if *prefix* is invalid or reserved (``ns`` followed by digits is reserved for the serializer). + The registry is meant for well-known prefixes of the application. + To choose the prefixes for a particular serialization, + use the *namespaces* parameter of :func:`tostring`, :func:`tostringlist` + and :meth:`ElementTree.write` instead. + .. versionadded:: 3.2 .. versionchanged:: next @@ -767,15 +772,17 @@ Functions .. function:: tostring(element, encoding="us-ascii", method="xml", *, \ xml_declaration=None, default_namespace=None, \ - short_empty_elements=True, standalone=None) + short_empty_elements=True, standalone=None, \ + namespaces=None) Generates a string representation of an XML element, including all subelements. *element* is an :class:`Element` instance. *encoding* [1]_ is the output encoding (default is US-ASCII). Use ``encoding="unicode"`` to generate a Unicode string (otherwise, a bytestring is generated). *method* is either ``"xml"``, ``"html"`` or ``"text"`` (default is ``"xml"``). - *xml_declaration*, *default_namespace*, *short_empty_elements* and - *standalone* has the same meaning as in :meth:`ElementTree.write`. + *xml_declaration*, *default_namespace*, *short_empty_elements*, + *standalone* and *namespaces* have the same meaning as in + :meth:`ElementTree.write`. Returns an (optionally) encoded string containing the XML data. .. versionchanged:: 3.4 @@ -789,20 +796,22 @@ Functions specified by the user. .. versionchanged:: next - Added the *standalone* parameter. + Added the *standalone* and *namespaces* parameters. .. function:: tostringlist(element, encoding="us-ascii", method="xml", *, \ xml_declaration=None, default_namespace=None, \ - short_empty_elements=True, standalone=None) + short_empty_elements=True, standalone=None, \ + namespaces=None) Generates a string representation of an XML element, including all subelements. *element* is an :class:`Element` instance. *encoding* [1]_ is the output encoding (default is US-ASCII). Use ``encoding="unicode"`` to generate a Unicode string (otherwise, a bytestring is generated). *method* is either ``"xml"``, ``"html"`` or ``"text"`` (default is ``"xml"``). - *xml_declaration*, *default_namespace*, *short_empty_elements* and - *standalone* has the same meaning as in :meth:`ElementTree.write`. + *xml_declaration*, *default_namespace*, *short_empty_elements*, + *standalone* and *namespaces* have the same meaning as in + :meth:`ElementTree.write`. Returns a list of (optionally) encoded strings containing the XML data. It does not guarantee any specific sequence, except that ``b"".join(tostringlist(element)) == tostring(element)``. @@ -820,7 +829,7 @@ Functions specified by the user. .. versionchanged:: next - Added the *standalone* parameter. + Added the *standalone* and *namespaces* parameters. .. function:: XML(text, parser=None) @@ -1258,7 +1267,8 @@ ElementTree Objects .. method:: write(file, encoding="us-ascii", xml_declaration=None, \ default_namespace=None, method="xml", *, \ - short_empty_elements=True, standalone=None) + short_empty_elements=True, standalone=None, \ + namespaces=None) Writes the element tree to a file, as XML. *file* is a file name, or a :term:`file object` opened for writing. *encoding* [1]_ is the output @@ -1281,6 +1291,14 @@ ElementTree Objects An XML declaration is written if *standalone* is not ``None``; combining it with ``xml_declaration=False`` raises a :exc:`ValueError`. + The keyword-only *namespaces* parameter is a mapping from namespace + prefixes to URIs, which is used to choose the prefixes for this + serialization instead of the prefixes registered with + :func:`register_namespace`. + Only the namespaces used in the tree are declared. + The empty prefix sets the default namespace, like *default_namespace*. + The prefixes are validated as in :func:`register_namespace`. + The output is either a string (:class:`str`) or binary (:class:`bytes`). This is controlled by the *encoding* argument. If *encoding* is ``"unicode"``, the output is a string; otherwise, it's binary. Note that @@ -1296,7 +1314,7 @@ ElementTree Objects by the user. .. versionchanged:: next - Added the *standalone* parameter. + Added the *standalone* and *namespaces* parameters. This is the XML file that is going to be manipulated:: diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 53983637f520c8..d8449042f2db90 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -751,6 +751,13 @@ xml now work for :class:`!DocumentFragment` nodes in :mod:`xml.dom.minidom`. (Contributed by Serhiy Storchaka in :gh:`54092`.) +* Add the *namespaces* parameter to :func:`~xml.etree.ElementTree.tostring`, + :func:`~xml.etree.ElementTree.tostringlist` and + :meth:`ElementTree.write `, + a mapping from namespace prefixes to URIs which chooses the prefixes + for this serialization instead of the global registry. + (Contributed by Serhiy Storchaka in :gh:`57587`.) + * :class:`~xml.etree.ElementTree.XMLPullParser` and :func:`~xml.etree.ElementTree.iterparse` now support the *target* parameter. The reported object is the value returned by the corresponding method of diff --git a/Lib/test/test_xml_etree.py b/Lib/test/test_xml_etree.py index 6f06c2c8f6a351..22b889fbb83f02 100644 --- a/Lib/test/test_xml_etree.py +++ b/Lib/test/test_xml_etree.py @@ -1082,6 +1082,84 @@ def test_tostring_default_namespace_attributes_html(self): '' ) + def test_tostring_namespaces(self): + # gh-57587: the prefixes for a particular serialization + house = 'http://localhost/house' + geo = 'http://localhost/geo' + elem = ET.XML('' % house) + self.assertEqual(serialize(elem), + '') + self.assertEqual(serialize(elem, namespaces={'house': house}), + '') + self.assertEqual(serialize(elem, namespaces={'home': house}), + '') + # the empty prefix sets the default namespace + self.assertEqual(serialize(elem, namespaces={'': house}), + '') + self.assertEqual(serialize(elem, namespaces={'': house}, + default_namespace=house), + '') + with self.assertRaisesRegex(ValueError, 'conflicting default'): + serialize(elem, namespaces={'': house}, default_namespace=geo) + # only the namespaces used in the tree are declared + self.assertEqual(serialize(elem, namespaces={'house': house, 'geo': geo}), + '') + + def test_tostring_namespaces_registry(self): + house = 'http://localhost/house' + geo = 'http://localhost/geo' + elem = ET.XML('' + '' + % (geo, house)) + ET.register_namespace('geo', geo) + self.addCleanup(ET._namespace_map.pop, geo, None) + self.assertEqual(serialize(elem), + '' + '') + # the mapping takes precedence over the registry + self.assertEqual(serialize(elem, namespaces={'g': geo}), + '' + '') + # a registered prefix is not used if the mapping reserves it + # for another namespace + self.assertEqual(serialize(elem, namespaces={'geo': house}), + '' + '') + + def test_tostring_namespaces_attributes(self): + house = 'http://localhost/house' + geo = 'http://localhost/geo' + elem = ET.Element('{%s}a' % house, {'{%s}k' % geo: 'v', 'x': '1'}) + self.assertEqual(serialize(elem, namespaces={'': house, 'g': geo}), + '') + # an attribute cannot use the default namespace + elem = ET.Element('{%s}a' % house, {'{%s}k' % house: 'v'}) + self.assertEqual(serialize(elem, namespaces={'': house, 'h': house}), + '') + + def test_tostring_namespaces_invalid(self): + elem = ET.XML('') + for namespaces in [{'ns0': 'uri'}, {'ns12': 'uri'}, {'xml': 'uri'}, + {'xmlns': 'uri'}, {'a:b': 'uri'}, {'1': 'uri'}, + {'a b': 'uri'}]: + with self.subTest(namespaces=namespaces): + self.assertRaises(ValueError, serialize, elem, + namespaces=namespaces) + for namespaces in [{1: 'uri'}, {'a': 1}, {b'a': 'uri'}]: + with self.subTest(namespaces=namespaces): + self.assertRaises(TypeError, serialize, elem, + namespaces=namespaces) + # the xml prefix can only be mapped to its namespace + self.assertEqual( + serialize(elem, namespaces={ + 'xml': 'http://www.w3.org/XML/1998/namespace'}), + '') + def test_tostring_standalone(self): elem = ET.XML('') self.assertEqual( diff --git a/Lib/xml/etree/ElementTree.py b/Lib/xml/etree/ElementTree.py index 10200671634013..e1c9093dcf328e 100644 --- a/Lib/xml/etree/ElementTree.py +++ b/Lib/xml/etree/ElementTree.py @@ -698,7 +698,8 @@ def write(self, file_or_filename, default_namespace=None, method=None, *, short_empty_elements=True, - standalone=None): + standalone=None, + namespaces=None): """Write element tree to a file as XML. Arguments: @@ -727,6 +728,11 @@ def write(self, file_or_filename, the XML declaration. If None (default), the standalone document declaration is omitted + *namespaces* -- a mapping from namespace prefixes to URIs which + overrides the prefixes registered with + register_namespace() for this serialization. + The empty prefix sets the default namespace + """ if self._root is None: raise TypeError('ElementTree not initialized') @@ -759,7 +765,7 @@ def write(self, file_or_filename, _serialize_text(write, self._root) else: qnames, attr_qnames, namespaces = _namespaces( - self._root, default_namespace) + self._root, default_namespace, namespaces) serialize = _serialize[method] serialize(write, self._root, qnames, attr_qnames, namespaces, short_empty_elements=short_empty_elements) @@ -817,9 +823,24 @@ def _get_writer(file_or_filename, encoding): stack.callback(file.detach) yield file.write, encoding -def _namespaces(elem, default_namespace=None): +def _namespaces(elem, default_namespace=None, prefix_map=None): # identify namespaces used in this tree + # maps uri:s to the prefixes preferred for this serialization + preferred = {} + if prefix_map is None: + prefix_map = {} + else: + for prefix, uri in prefix_map.items(): + _check_prefix(prefix, uri) + if not prefix: + if default_namespace is None: + default_namespace = uri + elif default_namespace != uri: + raise ValueError("conflicting default namespace") + else: + preferred.setdefault(uri, prefix) + # maps qnames to *encoded* prefix:local names qnames = {None: None} # The default namespace declaration does not apply to attribute names, @@ -848,7 +869,12 @@ def get_prefix(uri, isattr): prefix = prefixes.get(uri) if prefix is not None: return prefix - prefix = _namespace_map.get(uri) + prefix = preferred.get(uri) + if prefix is None: + prefix = _namespace_map.get(uri) + if prefix is not None and prefix in prefix_map: + # the prefix is reserved for other uri in this serialization + prefix = None 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 @@ -1152,7 +1178,7 @@ def _escape_attrib_html(text): def tostring(element, encoding=None, method=None, *, xml_declaration=None, default_namespace=None, - short_empty_elements=True, standalone=None): + short_empty_elements=True, standalone=None, namespaces=None): """Generate string representation of XML element. All subelements are included. If encoding is "unicode", a string @@ -1163,7 +1189,9 @@ def tostring(element, encoding=None, method=None, *, can be one of "xml" (default), "html" or "text", *default_namespace* sets the default XML namespace (for "xmlns"), *standalone* is the value of the standalone document declaration - in the XML declaration (omitted if None). + in the XML declaration (omitted if None), + *namespaces* is a mapping from namespace prefixes to URIs which + overrides the prefixes registered with register_namespace(). Returns an (optionally) encoded string containing the XML data. @@ -1174,7 +1202,8 @@ def tostring(element, encoding=None, method=None, *, default_namespace=default_namespace, method=method, short_empty_elements=short_empty_elements, - standalone=standalone) + standalone=standalone, + namespaces=namespaces) return stream.getvalue() class _ListDataStream(io.BufferedIOBase): @@ -1196,7 +1225,8 @@ def tell(self): def tostringlist(element, encoding=None, method=None, *, xml_declaration=None, default_namespace=None, - short_empty_elements=True, standalone=None): + short_empty_elements=True, standalone=None, + namespaces=None): lst = [] stream = _ListDataStream(lst) ElementTree(element).write(stream, encoding, @@ -1204,7 +1234,8 @@ def tostringlist(element, encoding=None, method=None, *, default_namespace=default_namespace, method=method, short_empty_elements=short_empty_elements, - standalone=standalone) + standalone=standalone, + namespaces=namespaces) return lst diff --git a/Misc/NEWS.d/next/Library/2026-09-14-10-00-00.gh-issue-57587.Xn4pQ2.rst b/Misc/NEWS.d/next/Library/2026-09-14-10-00-00.gh-issue-57587.Xn4pQ2.rst new file mode 100644 index 00000000000000..0593d2707a1e95 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-14-10-00-00.gh-issue-57587.Xn4pQ2.rst @@ -0,0 +1,5 @@ +Add the *namespaces* parameter to :func:`~xml.etree.ElementTree.tostring`, +:func:`~xml.etree.ElementTree.tostringlist` and +:meth:`~xml.etree.ElementTree.ElementTree.write`: a mapping from namespace +prefixes to URIs which chooses the prefixes for this serialization instead of +the global registry of :func:`~xml.etree.ElementTree.register_namespace`.