From 94bd452f75f552179c3c1f156bd792ddf97eb392 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 12 Aug 2026 17:02:21 +0300 Subject: [PATCH] gh-57336: Deprecate setting read-only attributes in xml.dom.minidom In the DOM model most node attributes are read-only, but in minidom only localName and length raised NoModificationAllowedErr. Setting the other ones silently left the node inconsistent with the document: for example, setting Attr.name renamed the attribute node, but the owner element still kept it under the old name. Setting them now emits a DeprecationWarning. They are also documented as read-only. --- Doc/library/xml.dom.rst | 16 +- Doc/whatsnew/3.16.rst | 12 ++ Lib/test/test_minidom.py | 37 +++- Lib/xml/dom/minicompat.py | 22 +- Lib/xml/dom/minidom.py | 192 +++++++++++++----- ...6-08-12-16-40-00.gh-issue-57336.Xk2mPv.rst | 7 + 6 files changed, 227 insertions(+), 59 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-12-16-40-00.gh-issue-57336.Xk2mPv.rst diff --git a/Doc/library/xml.dom.rst b/Doc/library/xml.dom.rst index 34e58dcad93012..bbcfc34aa90bfe 100644 --- a/Doc/library/xml.dom.rst +++ b/Doc/library/xml.dom.rst @@ -235,6 +235,11 @@ Node Objects All of the components of an XML document are subclasses of :class:`Node`. +.. versionchanged:: next + Setting a read-only attribute is deprecated. + It now emits a :exc:`DeprecationWarning` + and will raise :exc:`NoModificationAllowedErr` in a future version of Python. + .. attribute:: Node.nodeType @@ -304,8 +309,9 @@ All of the components of an XML document are subclasses of :class:`Node`. .. attribute:: Node.prefix - The part of the :attr:`tagName` preceding the colon if there is one, else the - empty string. The value is a string, or ``None``. + The part of the :attr:`Element.tagName` preceding the colon if there is one, + else the empty string. The value is a string, or ``None``. + This is a read-only attribute. .. attribute:: Node.namespaceURI @@ -455,12 +461,14 @@ following attributes: The public identifier for the external subset of the document type definition. This will be a string or ``None``. + This is a read-only attribute. .. attribute:: DocumentType.systemId The system identifier for the external subset of the document type definition. This will be a URI as a string, or ``None``. + This is a read-only attribute. .. attribute:: DocumentType.internalSubset @@ -474,6 +482,7 @@ following attributes: The name of the root element as given in the ``DOCTYPE`` declaration, if present. + This is a read-only attribute. .. attribute:: DocumentType.entities @@ -587,6 +596,7 @@ of that class. The element type name. In a namespace-using document it may have colons in it. The value is a string. + This is a read-only attribute. .. method:: Element.getElementsByTagName(tagName) @@ -690,6 +700,7 @@ Attr Objects The attribute name. In a namespace-using document it may include a colon. + This is a read-only attribute. .. attribute:: Attr.localName @@ -703,6 +714,7 @@ Attr Objects The part of the name preceding the colon if there is one, else the empty string. + This is a read-only attribute. .. attribute:: Attr.value diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index b017535b96979d..23fb120c3d7a1a 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -799,6 +799,18 @@ New deprecations open them one by one instead. (Contributed by Serhiy Storchaka in :gh:`152638`.) +* :mod:`xml.dom.minidom`: + + * Setting a read-only attribute of a node is deprecated. + It now emits a :exc:`DeprecationWarning` + and will raise :exc:`xml.dom.NoModificationAllowedErr` in the future. + This affects the :attr:`!nodeType`, :attr:`!nodeName`, :attr:`!name`, + :attr:`!tagName`, :attr:`!target`, :attr:`!prefix`, :attr:`!namespaceURI`, + :attr:`!publicId` and :attr:`!systemId` attributes. + Use ``Document.renameNode()`` to rename an element + or an attribute. + (Contributed by Serhiy Storchaka in :gh:`57336`.) + .. Add deprecations above alphabetically, not here at the end. .. include:: ../deprecations/pending-removal-in-3.17.rst diff --git a/Lib/test/test_minidom.py b/Lib/test/test_minidom.py index 46249e5138aed5..f71b364977b1ba 100644 --- a/Lib/test/test_minidom.py +++ b/Lib/test/test_minidom.py @@ -4,6 +4,7 @@ import pickle import io from test import support +from test.support import warnings_helper import unittest import xml.dom.minidom @@ -777,7 +778,9 @@ def _setupCloneElement(self, deep): self._testCloneElementCopiesAttributes( root, clone, "testCloneElement" + (deep and "Deep" or "Shallow")) # mutilate the original so shared data is detected - root.tagName = root.nodeName = "MODIFIED" + with warnings_helper.check_warnings( + ('', DeprecationWarning), quiet=True): + root.tagName = root.nodeName = "MODIFIED" root.setAttribute("attr", "NEW VALUE") root.setAttribute("added", "VALUE") return dom, clone @@ -1325,6 +1328,38 @@ def checkRenameNodeSharedConstraints(self, doc, node): self.assertRaises(xml.dom.WrongDocumentErr, doc2.renameNode, node, xml.dom.EMPTY_NAMESPACE, "foo") + def test_readonly_attributes(self): + # These attributes are read-only in the DOM, and setting them + # is deprecated (gh-57336). + doc = parseString('' + 'text') + elem = doc.documentElement + attr = elem.attributes["a"] + text, comment, pi, cdata = elem.childNodes + for node, name in [ + (doc, "nodeType"), (doc, "nodeName"), + (doc.doctype, "name"), (doc.doctype, "nodeName"), + (doc.doctype, "publicId"), (doc.doctype, "systemId"), + (elem, "nodeType"), + (elem, "tagName"), (elem, "nodeName"), + (elem, "prefix"), (elem, "namespaceURI"), + (attr, "name"), (attr, "nodeName"), + (attr, "prefix"), (attr, "namespaceURI"), + (text, "nodeName"), (comment, "nodeName"), (cdata, "nodeName"), + (pi, "nodeName"), (pi, "target"), + ]: + with self.subTest(node=type(node).__name__, name=name): + value = getattr(node, name) + with self.assertWarns(DeprecationWarning): + setattr(node, name, value) + self.assertEqual(getattr(node, name), value) + + # These are writable. + attr.value = "other" + text.data = "other" + self.assertEqual(attr.value, "other") + self.assertEqual(text.data, "other") + def testRenameAttribute(self): doc = parseString("") elem = doc.documentElement diff --git a/Lib/xml/dom/minicompat.py b/Lib/xml/dom/minicompat.py index 5d6fae9a2575bf..47fe54d7fabdff 100644 --- a/Lib/xml/dom/minicompat.py +++ b/Lib/xml/dom/minicompat.py @@ -40,7 +40,8 @@ # defproperty() should be used for each version of # the relevant _get_() function. -__all__ = ["NodeList", "EmptyNodeList", "StringTypes", "defproperty"] +__all__ = ["NodeList", "EmptyNodeList", "StringTypes", "defproperty", + "defdeprecatedproperty"] import xml.dom @@ -107,3 +108,22 @@ def set(self, value, name=name): "expected not to find _set_" + name prop = property(get, set, doc=doc) setattr(klass, name, prop) + + +def defdeprecatedproperty(klass, name, doc, private=None): + """Define a read-only attribute whose setter is deprecated. + + The value is stored in the *private* attribute ("_" + name by default). + Setting the attribute still works, but emits a DeprecationWarning. + """ + if private is None: + private = "_" + name + def get(self, private=private): + return getattr(self, private) + def set(self, value, name=name, private=private): + import warnings + warnings.warn(f"attempt to modify read-only attribute {name!r} " + f"is deprecated", DeprecationWarning, stacklevel=2) + setattr(self, private, value) + prop = property(get, set, doc=doc) + setattr(klass, name, prop) diff --git a/Lib/xml/dom/minidom.py b/Lib/xml/dom/minidom.py index 16b33b90184dc5..1b07fc1dd4459e 100644 --- a/Lib/xml/dom/minidom.py +++ b/Lib/xml/dom/minidom.py @@ -32,13 +32,19 @@ class Node(xml.dom.Node): - namespaceURI = None # this is non-null only for elements and attributes + _namespaceURI = None # this is non-null only for elements and attributes parentNode = None ownerDocument = None nextSibling = None previousSibling = None - prefix = EMPTY_PREFIX # non-null only for NS elements and attributes + _prefix = EMPTY_PREFIX # non-null only for NS elements and attributes + + def _get_namespaceURI(self): + return self._namespaceURI + + def _get_prefix(self): + return self._prefix def __bool__(self): return True @@ -277,6 +283,22 @@ def __enter__(self): def __exit__(self, et, ev, tb): self.unlink() +def _node_get_nodeType(self): + return self._nodeType +Node._get_nodeType = _node_get_nodeType +del _node_get_nodeType +def _node_get_nodeName(self): + return self._nodeName +Node._get_nodeName = _node_get_nodeName +del _node_get_nodeName + +defdeprecatedproperty(Node, "nodeType", doc="The type of this node.") +defdeprecatedproperty(Node, "nodeName", doc="The name of this node.") +defdeprecatedproperty(Node, "namespaceURI", + doc="The namespace URI of this node, or None.") +defdeprecatedproperty(Node, "prefix", + doc="The namespace prefix of this node, or None.") + defproperty(Node, "firstChild", doc="First child node, or None.") defproperty(Node, "lastChild", doc="Last child node, or None.") defproperty(Node, "localName", doc="Namespace-local name of this node.") @@ -334,8 +356,8 @@ def _get_elements_by_tagName_ns_helper(parent, nsURI, localName, rc): return rc class DocumentFragment(Node): - nodeType = Node.DOCUMENT_FRAGMENT_NODE - nodeName = "#document-fragment" + _nodeType = Node.DOCUMENT_FRAGMENT_NODE + _nodeName = "#document-fragment" nodeValue = None attributes = None parentNode = None @@ -352,9 +374,9 @@ def __init__(self): class Attr(Node): - __slots__=('_name', '_value', 'namespaceURI', + __slots__=('_name', '_value', '_namespaceURI', '_prefix', 'childNodes', '_localName', 'ownerDocument', 'ownerElement') - nodeType = Node.ATTRIBUTE_NODE + _nodeType = Node.ATTRIBUTE_NODE attributes = None specified = False _is_id = False @@ -366,7 +388,7 @@ def __init__(self, qName, namespaceURI=EMPTY_NAMESPACE, localName=None, self.ownerElement = None self.ownerDocument = None self._name = qName - self.namespaceURI = namespaceURI + self._namespaceURI = namespaceURI self._prefix = prefix if localName is not None: self._localName = localName @@ -389,11 +411,17 @@ def _get_specified(self): def _get_name(self): return self._name - def _set_name(self, value): + def _rename(self, value): self._name = value if self.ownerElement is not None: _clear_id_cache(self.ownerElement) + def _set_name(self, value): + import warnings + warnings.warn("attempt to modify read-only attribute 'name' " + "is deprecated", DeprecationWarning, stacklevel=2) + self._rename(value) + nodeName = name = property(_get_name, _set_name) def _get_value(self): @@ -411,7 +439,7 @@ def _set_value(self, value): def _get_prefix(self): return self._prefix - def _set_prefix(self, prefix): + def _reprefix(self, prefix): nsuri = self.namespaceURI if prefix == "xmlns": if nsuri and nsuri != XMLNS_NAMESPACE: @@ -424,10 +452,19 @@ def _set_prefix(self, prefix): newName = "%s:%s" % (prefix, self.localName) if self.ownerElement: _clear_id_cache(self.ownerElement) - self.name = newName + self._rename(newName) + + def _set_prefix(self, prefix): + import warnings + warnings.warn("attempt to modify read-only attribute 'prefix' " + "is deprecated", DeprecationWarning, stacklevel=2) + self._reprefix(prefix) prefix = property(_get_prefix, _set_prefix) + def _get_namespaceURI(self): + return self._namespaceURI + def unlink(self): # This implementation does not call the base implementation # since most of that is not needed, and the expense of the @@ -478,6 +515,8 @@ def _get_schemaType(self): defproperty(Attr, "isId", doc="True if this attribute is an ID.") defproperty(Attr, "localName", doc="Namespace-local name of this attribute.") defproperty(Attr, "schemaType", doc="Schema type for this attribute.") +defdeprecatedproperty(Attr, "namespaceURI", + doc="Namespace URI of this attribute, or None.") class NamedNodeMap(object): @@ -672,10 +711,10 @@ def _get_namespace(self): _no_type = TypeInfo(None, None) class Element(Node): - __slots__=('ownerDocument', 'parentNode', 'tagName', 'nodeName', 'prefix', - 'namespaceURI', '_localName', 'childNodes', '_attrs', '_attrsNS', + __slots__=('ownerDocument', 'parentNode', '_tagName', '_nodeName', '_prefix', + '_namespaceURI', '_localName', 'childNodes', '_attrs', '_attrsNS', 'nextSibling', 'previousSibling') - nodeType = Node.ELEMENT_NODE + _nodeType = Node.ELEMENT_NODE nodeValue = None schemaType = _no_type @@ -692,9 +731,9 @@ def __init__(self, tagName, namespaceURI=EMPTY_NAMESPACE, prefix=None, localName=None): self.ownerDocument = None self.parentNode = None - self.tagName = self.nodeName = tagName - self.prefix = prefix - self.namespaceURI = namespaceURI + self._tagName = self._nodeName = tagName + self._prefix = prefix + self._namespaceURI = namespaceURI self.childNodes = NodeList() self.nextSibling = self.previousSibling = None @@ -781,8 +820,8 @@ def setAttributeNS(self, namespaceURI, qualifiedName, value): if attr.isId: _clear_id_cache(self) if attr.prefix != prefix: - attr.prefix = prefix - attr.nodeName = qualifiedName + attr._prefix = prefix + attr._rename(qualifiedName) def getAttributeNode(self, attrname): if self._attrs is None: @@ -942,6 +981,20 @@ def setIdAttributeNode(self, idAttr): self.ownerDocument._magic_id_count += 1 _clear_id_cache(self) +def _element_get_tagName(self): + return self._tagName +Element._get_tagName = _element_get_tagName +del _element_get_tagName + +defdeprecatedproperty(Element, "tagName", + doc="Element name.") +defdeprecatedproperty(Element, "nodeName", + doc="Element name.") +defdeprecatedproperty(Element, "prefix", + doc="Namespace prefix of this element, or None.") +defdeprecatedproperty(Element, "namespaceURI", + doc="Namespace URI of this element, or None.") + defproperty(Element, "attributes", doc="NamedNodeMap of attributes on the element.") defproperty(Element, "localName", @@ -1001,13 +1054,16 @@ def replaceChild(self, newChild, oldChild): class ProcessingInstruction(Childless, Node): - nodeType = Node.PROCESSING_INSTRUCTION_NODE - __slots__ = ('target', 'data') + _nodeType = Node.PROCESSING_INSTRUCTION_NODE + __slots__ = ('_target', 'data') def __init__(self, target, data): - self.target = target + self._target = target self.data = data + def _get_target(self): + return self._target + # nodeValue is an alias for data def _get_nodeValue(self): return self.data @@ -1017,15 +1073,24 @@ def _set_nodeValue(self, value): # nodeName is an alias for target def _get_nodeName(self): - return self.target + return self._target + def _set_nodeName(self, value): - self.target = value + import warnings + warnings.warn("attempt to modify read-only attribute 'nodeName' " + "is deprecated", DeprecationWarning, stacklevel=2) + self._target = value + nodeName = property(_get_nodeName, _set_nodeName) def writexml(self, writer, indent="", addindent="", newl=""): writer.write("%s%s" % (indent,self.target, self.data, newl)) +defdeprecatedproperty(ProcessingInstruction, "target", + doc="The target of this processing instruction.") + + class CharacterData(Childless, Node): __slots__=('_data', 'ownerDocument','parentNode', 'previousSibling', 'nextSibling') @@ -1103,8 +1168,8 @@ def replaceData(self, offset, count, arg): class Text(CharacterData): __slots__ = () - nodeType = Node.TEXT_NODE - nodeName = "#text" + _nodeType = Node.TEXT_NODE + _nodeName = "#text" attributes = None def splitText(self, offset): @@ -1210,8 +1275,8 @@ def _get_containing_entref(node): class Comment(CharacterData): - nodeType = Node.COMMENT_NODE - nodeName = "#comment" + _nodeType = Node.COMMENT_NODE + _nodeName = "#comment" def __init__(self, data): CharacterData.__init__(self) @@ -1226,8 +1291,8 @@ def writexml(self, writer, indent="", addindent="", newl=""): class CDATASection(Text): __slots__ = () - nodeType = Node.CDATA_SECTION_NODE - nodeName = "#cdata-section" + _nodeType = Node.CDATA_SECTION_NODE + _nodeName = "#cdata-section" def writexml(self, writer, indent="", addindent="", newl=""): if self.data.find("]]>") >= 0: @@ -1304,24 +1369,24 @@ def __setstate__(self, state): class Identified: """Mix-in class that supports the publicId and systemId attributes.""" - __slots__ = 'publicId', 'systemId' + __slots__ = '_publicId', '_systemId' def _identified_mixin_init(self, publicId, systemId): - self.publicId = publicId - self.systemId = systemId + self._publicId = publicId + self._systemId = systemId def _get_publicId(self): - return self.publicId + return self._publicId def _get_systemId(self): - return self.systemId + return self._systemId class DocumentType(Identified, Childless, Node): - nodeType = Node.DOCUMENT_TYPE_NODE + _nodeType = Node.DOCUMENT_TYPE_NODE nodeValue = None - name = None - publicId = None - systemId = None + _name = None + _publicId = None + _systemId = None internalSubset = None def __init__(self, qualifiedName): @@ -1329,8 +1394,14 @@ def __init__(self, qualifiedName): self.notations = ReadOnlySequentialNamedNodeMap() if qualifiedName: prefix, localname = _nssplit(qualifiedName) - self.name = localname - self.nodeName = self.name + self._name = localname + self._nodeName = self._name + + def _get_name(self): + return self._name + + def _get_nodeName(self): + return self._nodeName def _get_internalSubset(self): return self.internalSubset @@ -1339,8 +1410,8 @@ def cloneNode(self, deep): if self.ownerDocument is None: # it's ok clone = DocumentType(None) - clone.name = self.name - clone.nodeName = self.name + clone._name = self._name + clone._nodeName = self._name operation = xml.dom.UserDataHandler.NODE_CLONED if deep: clone.entities._seq = [] @@ -1378,7 +1449,7 @@ def writexml(self, writer, indent="", addindent="", newl=""): class Entity(Identified, Node): attributes = None - nodeType = Node.ENTITY_NODE + _nodeType = Node.ENTITY_NODE nodeValue = None actualEncoding = None @@ -1386,7 +1457,7 @@ class Entity(Identified, Node): version = None def __init__(self, name, publicId, systemId, notation): - self.nodeName = name + self._nodeName = name self.notationName = notation self.childNodes = NodeList() self._identified_mixin_init(publicId, systemId) @@ -1417,14 +1488,26 @@ def replaceChild(self, newChild, oldChild): "cannot replace children of an entity node") class Notation(Identified, Childless, Node): - nodeType = Node.NOTATION_NODE + _nodeType = Node.NOTATION_NODE nodeValue = None def __init__(self, name, publicId, systemId): - self.nodeName = name + self._nodeName = name self._identified_mixin_init(publicId, systemId) +defdeprecatedproperty(DocumentType, "name", + doc="The name of the root element as given in the " + "DOCTYPE declaration.") +defdeprecatedproperty(DocumentType, "nodeName", + doc="The name of the root element as given in the " + "DOCTYPE declaration.") +defdeprecatedproperty(Identified, "publicId", + doc="Public identifier, or None.") +defdeprecatedproperty(Identified, "systemId", + doc="System identifier, or None.") + + class DOMImplementation(DOMImplementationLS): _features = [("core", "1.0"), ("core", "2.0"), @@ -1488,8 +1571,8 @@ def createDocument(self, namespaceURI, qualifiedName, doctype): def createDocumentType(self, qualifiedName, publicId, systemId): doctype = DocumentType(qualifiedName) - doctype.publicId = publicId - doctype.systemId = systemId + doctype._publicId = publicId + doctype._systemId = systemId return doctype # DOM Level 3 (WD 9 April 2002) @@ -1561,8 +1644,8 @@ class Document(Node, DocumentLS): Node.COMMENT_NODE, Node.DOCUMENT_TYPE_NODE) implementation = DOMImplementation() - nodeType = Node.DOCUMENT_NODE - nodeName = "#document" + _nodeType = Node.DOCUMENT_NODE + _nodeName = "#document" nodeValue = None attributes = None parentNode = None @@ -1873,15 +1956,14 @@ def renameNode(self, n, namespaceURI, name): element.removeAttributeNode(n) else: element = None - n.prefix = prefix + n._prefix = prefix n._localName = localName - n.namespaceURI = namespaceURI - n.nodeName = name + n._namespaceURI = namespaceURI if n.nodeType == Node.ELEMENT_NODE: - n.tagName = name + n._tagName = n._nodeName = name else: # attribute node - n.name = name + n._rename(name) if element is not None: element.setAttributeNode(n) if is_id: diff --git a/Misc/NEWS.d/next/Library/2026-08-12-16-40-00.gh-issue-57336.Xk2mPv.rst b/Misc/NEWS.d/next/Library/2026-08-12-16-40-00.gh-issue-57336.Xk2mPv.rst new file mode 100644 index 00000000000000..bd3ba397d44317 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-12-16-40-00.gh-issue-57336.Xk2mPv.rst @@ -0,0 +1,7 @@ +Setting read-only attributes of :mod:`xml.dom.minidom` nodes is now deprecated. +It emits a :exc:`DeprecationWarning` +and will raise :exc:`xml.dom.NoModificationAllowedErr` in the future. +This affects the :attr:`!nodeType`, :attr:`!nodeName`, :attr:`!name`, +:attr:`!tagName`, :attr:`!target`, :attr:`!prefix`, :attr:`!namespaceURI`, +:attr:`!publicId` and :attr:`!systemId` attributes. +Use ``Document.renameNode()`` to rename an element or an attribute.