diff --git a/Doc/library/xml.etree.elementtree.rst b/Doc/library/xml.etree.elementtree.rst index a61fb05bf99d87..24efcef66337bb 100644 --- a/Doc/library/xml.etree.elementtree.rst +++ b/Doc/library/xml.etree.elementtree.rst @@ -739,9 +739,19 @@ 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 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 + Invalid and reserved prefixes are now rejected. + .. function:: SubElement(parent, tag, /, attrib={}, **extra) @@ -762,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 @@ -784,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)``. @@ -815,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) @@ -1253,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 @@ -1276,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 @@ -1291,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 899947c1f8e0d7..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( @@ -2848,6 +2926,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..e1c9093dcf328e 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 @@ -697,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: @@ -726,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') @@ -758,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) @@ -816,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, @@ -847,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 @@ -1029,6 +1056,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 +1091,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] @@ -1129,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 @@ -1140,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. @@ -1151,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): @@ -1173,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, @@ -1181,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`. 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.