https://github.com/python/cpython/commit/d636e3c0282d3a09176f35c19cc9563b6bcb7c8d commit: d636e3c0282d3a09176f35c19cc9563b6bcb7c8d branch: main author: Serhiy Storchaka <[email protected]> committer: serhiy-storchaka <[email protected]> date: 2026-08-30T13:27:33+03:00 summary:
gh-44871: Improve DOM Level 1 conformance of xml.dom.minidom (#155641) * The factory methods now raise InvalidCharacterErr if the name is not a valid XML name. * Inserting or setting a node created by another document now raises WrongDocumentErr. * Setting an attribute node which belongs to another element now raises InuseAttributeErr. * Inserting a node into itself or its descendant now raises HierarchyRequestErr. * A document can have only one element child and only one document type child, and a document fragment can no longer have notation children. * Removing an absent attribute now has no effect instead of raising NotFoundErr. * Attributes defaulted in the DTD are no longer omitted when parsing, and Attr.specified now reports whether the attribute was given in the start tag. This needs the new pyexpat method GetSpecifiedAttributeCount(). * EntityReference nodes and Document.createEntityReference() are now implemented. * The exceptions required by the DOM are now documented, and minidom specific differences are described in the minidom documentation. Co-authored-by: Jason Orendorff <[email protected]> files: A Misc/NEWS.d/next/Library/2026-08-12-21-34-12.gh-issue-44871.Qw3Lm8.rst A Misc/NEWS.d/next/Library/2026-08-12-21-34-12.gh-issue-44871.Rt7Nv2.rst M Doc/library/pyexpat.rst M Doc/library/xml.dom.minidom.rst M Doc/library/xml.dom.rst M Doc/whatsnew/3.16.rst M Lib/test/test_minidom.py M Lib/xml/dom/expatbuilder.py M Lib/xml/dom/minidom.py M Modules/clinic/pyexpat.c.h M Modules/pyexpat.c diff --git a/Doc/library/pyexpat.rst b/Doc/library/pyexpat.rst index c88411ce0b7b91f..abd68c5006bd475 100644 --- a/Doc/library/pyexpat.rst +++ b/Doc/library/pyexpat.rst @@ -172,6 +172,20 @@ XMLParser Objects or ``None`` if :meth:`SetBase` hasn't been called. +.. method:: xmlparser.GetSpecifiedAttributeCount() + + Return the index just past the attributes given in the start tag. + Attributes defaulted from the DTD follow the specified ones, + so attributes at lower indices in the list + passed to :attr:`StartElementHandler` were given in the start tag. + Each attribute takes two items in that list, + its name and its value. + Only meaningful inside a :attr:`StartElementHandler` call, + and only if :attr:`ordered_attributes` is true. + + .. versionadded:: next + + .. method:: xmlparser.GetInputContext() Returns the input data that generated the current event as a string. The data is diff --git a/Doc/library/xml.dom.minidom.rst b/Doc/library/xml.dom.minidom.rst index ded3859192fd835..efc81f31e36a5be 100644 --- a/Doc/library/xml.dom.minidom.rst +++ b/Doc/library/xml.dom.minidom.rst @@ -261,23 +261,31 @@ rules apply: :attr:`~xml.dom.DocumentType.notations` are read-only and support only ``len()`` and subscription by a name. -* :attr:`~xml.dom.Document.strictErrorChecking` and - :attr:`~xml.dom.Attr.specified` are always ``False``. +* :attr:`~xml.dom.Document.strictErrorChecking` is always ``False``. -* :meth:`~xml.dom.Element.removeAttribute` and - :meth:`~xml.dom.Element.removeAttributeNS` raise - :exc:`~xml.dom.NotFoundErr` if there is no matching attribute, - while the DOM specifies that this has no effect. + .. versionchanged:: next + Previously, :attr:`~xml.dom.Attr.specified` was always ``False``. + +* The constraints of the DOM are now enforced, + and the corresponding exceptions are raised. + + .. versionchanged:: next + Previously, many invalid operations silently succeeded + and produced an invalid document, + but removing an absent attribute raised :exc:`~xml.dom.NotFoundErr`. The following interfaces have no implementation in :mod:`!xml.dom.minidom`: * :class:`DOMTimeStamp` -* :class:`EntityReference` - -Most of these reflect information in the XML document that is not of general +This reflects information in the XML document that is not of general utility to most DOM users. +.. versionchanged:: next + :class:`~xml.dom.EntityReference` is now implemented. + Note that the parser expands entity references, + so they only occur in a document if created explicitly. + .. rubric:: Footnotes .. [1] The encoding name included in the XML output should conform to diff --git a/Doc/library/xml.dom.rst b/Doc/library/xml.dom.rst index 97573388458ac51..250b9be99a8bd39 100644 --- a/Doc/library/xml.dom.rst +++ b/Doc/library/xml.dom.rst @@ -232,6 +232,8 @@ DOM Level 2 added the ability to create new :class:`Document` and *qualifiedName*, *publicId*, and *systemId* strings, representing the information contained in an XML document type declaration. + Raise :exc:`InvalidCharacterErr` if the name is not a valid XML name. + .. _dom-node-objects: @@ -457,6 +459,10 @@ for each node type are: children, returning *newChild*. If the node was already in the tree, it is removed first. + Raise :exc:`WrongDocumentErr` if *newChild* was created by another + document, and :exc:`HierarchyRequestErr` if it is this node itself or + its ancestor. + .. method:: Node.insertBefore(newChild, refChild) @@ -465,6 +471,10 @@ for each node type are: *newChild* is returned. If *refChild* is ``None``, it inserts *newChild* at the end of the children's list. + Raise :exc:`WrongDocumentErr` if *newChild* was created by another + document, and :exc:`HierarchyRequestErr` if it is this node itself or + its ancestor. + .. method:: Node.removeChild(oldChild) @@ -479,6 +489,10 @@ for each node type are: Replace an existing node with a new node. It must be the case that *oldChild* is a child of this node; if not, :exc:`NotFoundErr` is raised. + Raise :exc:`WrongDocumentErr` if *newChild* was created by another + document, and :exc:`HierarchyRequestErr` if it is this node itself or + its ancestor. + .. method:: Node.normalize() @@ -665,6 +679,8 @@ inherits properties from :class:`Node`. document when it is created. You need to explicitly insert it with one of the other methods such as :meth:`~Node.insertBefore` or :meth:`~Node.appendChild`. + Raise :exc:`InvalidCharacterErr` if the name is not a valid XML name. + .. method:: Document.createElementNS(namespaceURI, tagName) @@ -673,6 +689,8 @@ inherits properties from :class:`Node`. need to explicitly insert it with one of the other methods such as :meth:`~Node.insertBefore` or :meth:`~Node.appendChild`. + Raise :exc:`InvalidCharacterErr` if the name is not a valid XML name. + .. method:: Document.createTextNode(data) @@ -681,6 +699,16 @@ inherits properties from :class:`Node`. tree. +.. method:: Document.createEntityReference(name) + + Create and return a new entity reference node. + The node is not inserted into the document when it is created. + + Raise :exc:`InvalidCharacterErr` if the name is not a valid XML name. + + .. versionadded:: next + + .. method:: Document.createComment(data) Create and return a comment node containing the data passed as a parameter. As @@ -694,6 +722,8 @@ inherits properties from :class:`Node`. *data* passed as parameters. As with the other creation methods, this one does not insert the node into the tree. + Raise :exc:`InvalidCharacterErr` if the name is not a valid XML name. + .. method:: Document.createAttribute(name) @@ -702,6 +732,8 @@ inherits properties from :class:`Node`. :meth:`~Element.setAttributeNode` on the appropriate :class:`Element` object to use the newly created attribute instance. + Raise :exc:`InvalidCharacterErr` if the name is not a valid XML name. + .. method:: Document.createAttributeNS(namespaceURI, qualifiedName) @@ -710,6 +742,8 @@ inherits properties from :class:`Node`. element. You must use :meth:`~Element.setAttributeNode` on the appropriate :class:`Element` object to use the newly created attribute instance. + Raise :exc:`InvalidCharacterErr` if the name is not a valid XML name. + .. method:: Document.getElementById(id) @@ -844,6 +878,8 @@ of that class. Set an attribute value from a string. + Raise :exc:`InvalidCharacterErr` if the name is not a valid XML name. + .. method:: Element.setAttributeNode(newAttr) @@ -852,6 +888,8 @@ of that class. occurs, the old attribute node will be returned. If *newAttr* is already in use, :exc:`InuseAttributeErr` will be raised. + Raise :exc:`WrongDocumentErr` if *newAttr* was created by another document. + .. method:: Element.setAttributeNodeNS(newAttr) @@ -861,12 +899,16 @@ of that class. returned. If *newAttr* is already in use, :exc:`InuseAttributeErr` will be raised. + Raise :exc:`WrongDocumentErr` if *newAttr* was created by another document. + .. method:: Element.setAttributeNS(namespaceURI, qname, value) Set an attribute value from a string, given a *namespaceURI* and a *qname*. Note that a qname is the whole attribute name. This is different than above. + Raise :exc:`InvalidCharacterErr` if the name is not a valid XML name. + .. _dom-attr-objects: @@ -972,6 +1014,9 @@ NamedNodeMap Objects Add *node* to the map, using its :attr:`~Attr.name` as the key. Return the node which it replaces, or ``None`` if it replaces no node. + Raise :exc:`WrongDocumentErr` if *node* was created by another document, + and :exc:`InuseAttributeErr` if it belongs to another element. + .. method:: NamedNodeMap.setNamedItemNS(node) @@ -979,6 +1024,9 @@ NamedNodeMap Objects using its namespace URI and local name as the key. Return the node which it replaces, or ``None`` if it replaces no node. + Raise :exc:`WrongDocumentErr` if *node* was created by another document, + and :exc:`InuseAttributeErr` if it belongs to another element. + .. method:: NamedNodeMap.removeNamedItem(name) @@ -1224,6 +1272,23 @@ The name of the notation is its :attr:`~Node.nodeName`. The system identifier of the notation, or ``None`` if it is not specified. This is a read-only attribute. +.. _dom-entityreference-objects: + +EntityReference Objects +^^^^^^^^^^^^^^^^^^^^^^^ + +.. class:: EntityReference + :no-typesetting: + +:class:`EntityReference` represents an entity reference in the XML document. +It is a subclass of :class:`Node`. +The name of the referenced entity is its :attr:`~Node.nodeName`. +Its children are the replacement text of the entity, and are read-only. + +Parsers may expand entity references, +so such a node only occurs in a document if it was created explicitly. + +.. versionadded:: next .. _dom-exceptions: diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index a1a8415482b97aa..f3ddae7a2b2fdd1 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -630,6 +630,24 @@ xml instead of failing later, when encountering non-ASCII data. (Contributed by Serhiy Storchaka in :gh:`62259`.) +* :mod:`xml.dom.minidom` now conforms closer to the DOM Level 1 specification. + It checks names passed to the factory methods, + rejects inserting or setting a node created by another document + or making a node a descendant of itself, + rejects a second element or document type child of a document + and a notation child of a document fragment, + reports attributes defaulted in the DTD + and whether an attribute was given in the start tag, + and implements :class:`!EntityReference` nodes + and :meth:`!Document.createEntityReference`. + (Contributed by Jason Orendorff and Serhiy Storchaka in :gh:`44871`.) + +* Add :meth:`!GetSpecifiedAttributeCount` method + to the :mod:`XML parser <xml.parsers.expat>` objects. + It tells how many of the reported attributes were given in the start tag + rather than defaulted from the DTD. + (Contributed by Jason Orendorff and Serhiy Storchaka in :gh:`44871`.) + zipfile ------- @@ -845,6 +863,24 @@ that may require changes to your code. :exc:`TypeError`. (Contributed by Serhiy Storchaka in :gh:`152587`.) +* :mod:`xml.dom.minidom` now raises :exc:`~xml.dom.InvalidCharacterErr` + for a name which is not a valid XML name, + :exc:`~xml.dom.WrongDocumentErr` + for inserting or setting a node created by another document, + :exc:`~xml.dom.InuseAttributeErr` for setting an attribute node + which belongs to another element, + and :exc:`~xml.dom.HierarchyRequestErr` for inserting a node into itself + or its descendant. + It also rejects a second element or document type child of a document + and a notation child of a document fragment. + Such operations formerly succeeded + and produced an invalid document or an endless loop. + On the other hand, :meth:`!Element.removeAttribute` and + :meth:`!Element.removeAttributeNS` no longer raise + :exc:`~xml.dom.NotFoundErr` if there is no matching attribute. + Attributes defaulted in the DTD are no longer omitted when parsing. + (Contributed by Jason Orendorff and Serhiy Storchaka in :gh:`44871`.) + * On Windows, seeking a pipe now fails instead of silently appearing to succeed: :func:`os.lseek` and :meth:`~io.IOBase.seek` raise :exc:`OSError`, and :meth:`~io.IOBase.seekable` returns ``False``. As a consequence, diff --git a/Lib/test/test_minidom.py b/Lib/test/test_minidom.py index 46249e5138aed52..3735a6046891ea9 100644 --- a/Lib/test/test_minidom.py +++ b/Lib/test/test_minidom.py @@ -354,7 +354,9 @@ def testRemoveAttr(self): child.setAttribute("def", "ghi") self.assertEqual(len(child.attributes), 1) - self.assertRaises(xml.dom.NotFoundErr, child.removeAttribute, "foo") + # removing an absent attribute has no effect + child.removeAttribute("foo") + self.assertEqual(len(child.attributes), 1) child.removeAttribute("def") self.assertEqual(len(child.attributes), 0) dom.unlink() @@ -366,8 +368,8 @@ def testRemoveAttrNS(self): child.setAttributeNS("http://www.w3.org", "xmlns:python", "http://www.python.org") child.setAttributeNS("http://www.python.org", "python:abcattr", "foo") - self.assertRaises(xml.dom.NotFoundErr, child.removeAttributeNS, - "foo", "http://www.python.org") + # removing an absent attribute has no effect + child.removeAttributeNS("foo", "http://www.python.org") self.assertEqual(len(child.attributes), 2) child.removeAttributeNS("http://www.python.org", "abcattr") self.assertEqual(len(child.attributes), 1) @@ -1784,5 +1786,190 @@ def test_cdata_parsing(self): dom2 = parseString(dom1.toprettyxml()) self.checkWholeText(dom2.getElementsByTagName('node')[0].firstChild, '</data>') + def testInvalidCharacterErr(self): + doc = parseString("<doc/>") + impl = getDOMImplementation() + for name in ("", "bad name", "1st", "-x", ".x", "a<b", "a&b", "a\tb"): + with self.subTest(name=name): + self.assertRaises(xml.dom.InvalidCharacterErr, + doc.createElement, name) + self.assertRaises(xml.dom.InvalidCharacterErr, + doc.createElementNS, None, name) + self.assertRaises(xml.dom.InvalidCharacterErr, + doc.createAttribute, name) + self.assertRaises(xml.dom.InvalidCharacterErr, + doc.createAttributeNS, None, name) + self.assertRaises(xml.dom.InvalidCharacterErr, + doc.createProcessingInstruction, name, "") + self.assertRaises(xml.dom.InvalidCharacterErr, + doc.createEntityReference, name) + self.assertRaises(xml.dom.InvalidCharacterErr, + impl.createDocumentType, name, None, None) + self.assertRaises(xml.dom.InvalidCharacterErr, + doc.documentElement.setAttribute, name, "v") + self.assertRaises(xml.dom.InvalidCharacterErr, + doc.documentElement.setAttributeNS, + None, name, "v") + for name in ("a", "_x", ":x", "a.b-c", "ns:tag", "a1", + "\N{GREEK CAPITAL LETTER OMEGA}", "\N{LINEAR B SYLLABLE B008 A}x"): + with self.subTest(name=name): + self.assertEqual(doc.createElement(name).tagName, name) + self.assertEqual(doc.createAttribute(name).name, name) + doc.unlink() + + def testWrongDocumentErr(self): + doc = parseString("<doc><child/></doc>") + other = parseString("<other/>") + elem = doc.documentElement + alien = other.createElement("alien") + self.assertRaises(xml.dom.WrongDocumentErr, elem.appendChild, alien) + self.assertRaises(xml.dom.WrongDocumentErr, elem.insertBefore, + alien, elem.firstChild) + self.assertRaises(xml.dom.WrongDocumentErr, elem.replaceChild, + alien, elem.firstChild) + self.assertRaises(xml.dom.WrongDocumentErr, doc.appendChild, alien) + # the rejected node is left alone + self.assertIs(alien.ownerDocument, other) + self.assertIsNone(alien.parentNode) + # importNode() is the supported way to do this + elem.appendChild(doc.importNode(alien, True)) + self.assertEqual(elem.lastChild.tagName, "alien") + doc.unlink() + other.unlink() + + def testAncestorLoops(self): + doc = parseString("<doc><child><grandchild/></child></doc>") + elem = doc.documentElement + child = elem.firstChild + grandchild = child.firstChild + for node in elem, child, grandchild: + self.assertRaises(xml.dom.HierarchyRequestErr, + node.appendChild, node) + self.assertRaises(xml.dom.HierarchyRequestErr, child.appendChild, elem) + self.assertRaises(xml.dom.HierarchyRequestErr, + grandchild.appendChild, elem) + self.assertRaises(xml.dom.HierarchyRequestErr, + grandchild.insertBefore, child, None) + self.assertRaises(xml.dom.HierarchyRequestErr, + grandchild.replaceChild, elem, None) + # the tree is unchanged + self.assertIs(child.parentNode, elem) + self.assertIs(grandchild.parentNode, child) + doc.unlink() + + def testOnlyOneElementAndDocumentType(self): + impl = getDOMImplementation() + doc = impl.createDocument(None, "root", None) + elem = doc.documentElement + comment = doc.appendChild(doc.createComment("c")) + self.assertRaises(xml.dom.HierarchyRequestErr, + doc.appendChild, doc.createElement("x")) + self.assertRaises(xml.dom.HierarchyRequestErr, + doc.insertBefore, doc.createElement("x"), comment) + self.assertRaises(xml.dom.HierarchyRequestErr, + doc.replaceChild, doc.createElement("x"), comment) + # replacing the document element and reordering it are allowed + other = doc.createElement("other") + doc.replaceChild(other, elem) + self.assertIs(doc.documentElement, other) + doc.appendChild(other) + self.assertEqual([n.nodeName for n in doc.childNodes], + ["#comment", "other"]) + + doc2 = impl.createDocument(None, None, None) + doctype = impl.createDocumentType("a", None, None) + doc2.appendChild(doctype) + self.assertRaises(xml.dom.HierarchyRequestErr, doc2.appendChild, + impl.createDocumentType("b", None, None)) + # re-adding the same node and replacing it are allowed + doc2.appendChild(doctype) + doc2.replaceChild(impl.createDocumentType("b", None, None), doctype) + doc.unlink() + doc2.unlink() + + def testDocumentFragmentChildren(self): + doc = parseString('<!DOCTYPE doc [<!NOTATION n SYSTEM "n">]><doc/>') + frag = doc.createDocumentFragment() + notation = doc.doctype.notations.item(0) + self.assertRaises(xml.dom.HierarchyRequestErr, + frag.appendChild, notation) + frag.appendChild(doc.createElement("e")) + frag.appendChild(doc.createTextNode("t")) + frag.appendChild(doc.createComment("c")) + self.assertEqual(len(frag.childNodes), 3) + doc.unlink() + + def testSetAttributeNodeWrongDocument(self): + doc = parseString("<doc a='v'/>") + other = parseString("<other b='w'/>") + elem = doc.documentElement + self.assertRaises(xml.dom.WrongDocumentErr, elem.setAttributeNode, + other.createAttribute("z")) + self.assertRaises(xml.dom.WrongDocumentErr, elem.setAttributeNodeNS, + other.createAttributeNS(None, "z")) + # an imported attribute belongs to this document and is accepted + elem.setAttributeNode(doc.importNode(other.createAttribute("z"), True)) + self.assertTrue(elem.hasAttribute("z")) + # re-setting an own attribute is not an error + elem.setAttributeNode(elem.getAttributeNode("a")) + + # the same checks are applied in NamedNodeMap.setNamedItem() + attrs = elem.attributes + self.assertRaises(xml.dom.WrongDocumentErr, attrs.setNamedItem, + other.createAttribute("y")) + self.assertRaises(xml.dom.WrongDocumentErr, attrs.setNamedItemNS, + other.createAttributeNS(None, "y")) + attrs.setNamedItem(doc.createAttribute("y")) + self.assertTrue(elem.hasAttribute("y")) + + # an attribute of another element of the same document is in use + doc2 = parseString("<doc><a x='v'/><b/></doc>") + a, b = doc2.documentElement.childNodes + self.assertRaises(xml.dom.InuseAttributeErr, b.attributes.setNamedItem, + a.getAttributeNode("x")) + doc2.unlink() + doc.unlink() + other.unlink() + + def testAttrSpecified(self): + doc = parseString("<!DOCTYPE doc [" + " <!ELEMENT doc EMPTY>" + " <!ATTLIST doc a CDATA 'default' b CDATA #IMPLIED>" + "]><doc b='given'/>") + elem = doc.documentElement + # attributes defaulted from the DTD are reported too + self.assertEqual(sorted(elem.attributes.keys()), ["a", "b"]) + self.assertEqual(elem.getAttribute("a"), "default") + self.assertFalse(elem.getAttributeNode("a").specified) + self.assertEqual(elem.getAttribute("b"), "given") + self.assertTrue(elem.getAttributeNode("b").specified) + doc.unlink() + + def testEntityReference(self): + doc = parseString("<doc/>") + ref = doc.createEntityReference("ent") + self.assertEqual(ref.nodeType, Node.ENTITY_REFERENCE_NODE) + self.assertEqual(ref.nodeName, "ent") + self.assertIsNone(ref.nodeValue) + self.assertIs(ref.ownerDocument, doc) + doc.documentElement.appendChild(ref) + self.assertEqual(doc.documentElement.toxml(), "<doc>&ent;</doc>") + # entity reference nodes are read-only + text = doc.createTextNode("x") + self.assertRaises(xml.dom.NoModificationAllowedErr, + ref.appendChild, text) + self.assertRaises(xml.dom.NoModificationAllowedErr, + ref.insertBefore, text, None) + self.assertRaises(xml.dom.NoModificationAllowedErr, + ref.removeChild, text) + self.assertRaises(xml.dom.NoModificationAllowedErr, + ref.replaceChild, text, None) + self.assertEqual(ref.cloneNode(True).nodeName, "ent") + other = parseString("<other/>") + self.assertEqual(other.importNode(ref, True).nodeName, "ent") + doc.unlink() + other.unlink() + + if __name__ == "__main__": unittest.main() diff --git a/Lib/xml/dom/expatbuilder.py b/Lib/xml/dom/expatbuilder.py index 7dd667bf3fbe042..d56b2ddfdb25698 100644 --- a/Lib/xml/dom/expatbuilder.py +++ b/Lib/xml/dom/expatbuilder.py @@ -159,7 +159,6 @@ def getParser(self): self._intern_setdefault = self._parser.intern.setdefault self._parser.buffer_text = True self._parser.ordered_attributes = True - self._parser.specified_attributes = True self.install(self._parser) return self._parser @@ -352,11 +351,13 @@ def start_element_handler(self, name, attributes): self.curNode = node if attributes: + specified = self.getParser().GetSpecifiedAttributeCount() for i in range(0, len(attributes), 2): a = minidom.Attr(attributes[i], EMPTY_NAMESPACE, None, EMPTY_PREFIX) value = attributes[i+1] a.value = value + a.specified = i < specified a.ownerDocument = self.document _set_attribute_node(node, a) @@ -760,6 +761,7 @@ def start_element_handler(self, name, attributes): node._ensure_attributes() _attrs = node._attrs _attrsNS = node._attrsNS + specified = self.getParser().GetSpecifiedAttributeCount() for i in range(0, len(attributes), 2): aname = attributes[i] value = attributes[i+1] @@ -775,6 +777,7 @@ def start_element_handler(self, name, attributes): _attrsNS[(EMPTY_NAMESPACE, aname)] = a a.ownerDocument = self.document a.value = value + a.specified = i < specified a.ownerElement = node if __debug__: diff --git a/Lib/xml/dom/minidom.py b/Lib/xml/dom/minidom.py index 16b33b90184dc59..5fd3911bd3c9ebb 100644 --- a/Lib/xml/dom/minidom.py +++ b/Lib/xml/dom/minidom.py @@ -16,6 +16,7 @@ """ import io +import xml import xml.dom from xml.dom import EMPTY_NAMESPACE, EMPTY_PREFIX, XMLNS_NAMESPACE, domreg @@ -79,15 +80,32 @@ def _get_lastChild(self): if self.childNodes: return self.childNodes[-1] + def _check_new_child(self, newChild, oldChild=None): + # Common checks for insertBefore(), appendChild() and replaceChild(). + doc = self.ownerDocument or self + newChildDoc = newChild.ownerDocument + if newChildDoc is not doc and newChildDoc is not None: + raise xml.dom.WrongDocumentErr( + "%s was created by a different document" % repr(newChild)) + if newChild.nodeType not in self._child_node_types: + raise xml.dom.HierarchyRequestErr( + "%s cannot be child of %s" % (repr(newChild), repr(self))) + if newChild is self: + raise xml.dom.HierarchyRequestErr( + "%s cannot be child of itself" % repr(self)) + # A node without children cannot be an ancestor, and testing this + # first keeps appending leaf nodes linear in the depth of the tree. + if newChild.childNodes and _is_ancestor(newChild, self): + raise xml.dom.HierarchyRequestErr( + "%s is an ancestor of %s" % (repr(newChild), repr(self))) + def insertBefore(self, newChild, refChild): if newChild.nodeType == self.DOCUMENT_FRAGMENT_NODE: for c in tuple(newChild.childNodes): self.insertBefore(c, refChild) ### The DOM does not clearly specify what to return in this case return newChild - if newChild.nodeType not in self._child_node_types: - raise xml.dom.HierarchyRequestErr( - "%s cannot be child of %s" % (repr(newChild), repr(self))) + self._check_new_child(newChild) if newChild.parentNode is not None: newChild.parentNode.removeChild(newChild) if refChild is None: @@ -117,10 +135,8 @@ def appendChild(self, node): self.appendChild(c) ### The DOM does not clearly specify what to return in this case return node - if node.nodeType not in self._child_node_types: - raise xml.dom.HierarchyRequestErr( - "%s cannot be child of %s" % (repr(node), repr(self))) - elif node.nodeType in _nodeTypes_with_children: + self._check_new_child(node) + if node.nodeType in _nodeTypes_with_children: _clear_id_cache(self) if node.parentNode is not None: node.parentNode.removeChild(node) @@ -133,11 +149,9 @@ def replaceChild(self, newChild, oldChild): refChild = oldChild.nextSibling self.removeChild(oldChild) return self.insertBefore(newChild, refChild) - if newChild.nodeType not in self._child_node_types: - raise xml.dom.HierarchyRequestErr( - "%s cannot be child of %s" % (repr(newChild), repr(self))) if newChild is oldChild: return + self._check_new_child(newChild, oldChild) if newChild.parentNode is not None: newChild.parentNode.removeChild(newChild) try: @@ -282,6 +296,22 @@ def __exit__(self, et, ev, tb): defproperty(Node, "localName", doc="Namespace-local name of this node.") +def _check_name(name): + if not xml.is_valid_name(name): + raise xml.dom.InvalidCharacterErr( + "%r is not a valid XML name" % (name,)) + + +def _is_ancestor(node, other): + "Returns true iff node is an ancestor of other." + other = other.parentNode + while other is not None: + if other is node: + return True + other = other.parentNode + return False + + def _append_child(self, node): # fast path with less checks; usable by DOM builders if careful childNodes = self.childNodes @@ -344,8 +374,7 @@ class DocumentFragment(Node): Node.CDATA_SECTION_NODE, Node.ENTITY_REFERENCE_NODE, Node.PROCESSING_INSTRUCTION_NODE, - Node.COMMENT_NODE, - Node.NOTATION_NODE) + Node.COMMENT_NODE) def __init__(self): self.childNodes = NodeList() @@ -620,6 +649,12 @@ def setNamedItem(self, node): if not isinstance(node, Attr): raise xml.dom.HierarchyRequestErr( "%s cannot be child of %s" % (repr(node), repr(self))) + owner = self._ownerElement + if node.ownerDocument not in (None, owner.ownerDocument): + raise xml.dom.WrongDocumentErr( + "%s was created by another document" % repr(node)) + if node.ownerElement not in (None, owner): + raise xml.dom.InuseAttributeErr("attribute node already owned") old = self._attrs.get(node.name) if old: old.unlink() @@ -758,6 +793,7 @@ def getAttributeNS(self, namespaceURI, localName): def setAttribute(self, attname, value): attr = self.getAttributeNode(attname) if attr is None: + _check_name(attname) attr = Attr(attname) attr.value = value # also sets nodeValue attr.ownerDocument = self.ownerDocument @@ -771,6 +807,7 @@ def setAttributeNS(self, namespaceURI, qualifiedName, value): prefix, localname = _nssplit(qualifiedName) attr = self.getAttributeNodeNS(namespaceURI, localname) if attr is None: + _check_name(qualifiedName) attr = Attr(qualifiedName, namespaceURI, localname, prefix) attr.value = value attr.ownerDocument = self.ownerDocument @@ -795,6 +832,9 @@ def getAttributeNodeNS(self, namespaceURI, localName): return self._attrsNS.get((namespaceURI, localName)) def setAttributeNode(self, attr): + if attr.ownerDocument not in (None, self.ownerDocument): + raise xml.dom.WrongDocumentErr( + "%s was created by another document" % repr(attr)) if attr.ownerElement not in (None, self): raise xml.dom.InuseAttributeErr("attribute node already owned") self._ensure_attributes() @@ -816,22 +856,17 @@ def setAttributeNode(self, attr): setAttributeNodeNS = setAttributeNode def removeAttribute(self, name): - if self._attrsNS is None: - raise xml.dom.NotFoundErr() - try: - attr = self._attrs[name] - except KeyError: - raise xml.dom.NotFoundErr() - self.removeAttributeNode(attr) + # The DOM specifies that removing an absent attribute has no effect. + if self._attrs is not None: + attr = self._attrs.get(name) + if attr is not None: + self.removeAttributeNode(attr) def removeAttributeNS(self, namespaceURI, localName): - if self._attrsNS is None: - raise xml.dom.NotFoundErr() - try: - attr = self._attrsNS[(namespaceURI, localName)] - except KeyError: - raise xml.dom.NotFoundErr() - self.removeAttributeNode(attr) + if self._attrsNS is not None: + attr = self._attrsNS.get((namespaceURI, localName)) + if attr is not None: + self.removeAttributeNode(attr) def removeAttributeNode(self, node): if node is None: @@ -1416,6 +1451,45 @@ def replaceChild(self, newChild, oldChild): raise xml.dom.HierarchyRequestErr( "cannot replace children of an entity node") +class EntityReference(Node): + nodeType = Node.ENTITY_REFERENCE_NODE + nodeValue = None + attributes = None + + _child_node_types = (Node.ELEMENT_NODE, + Node.PROCESSING_INSTRUCTION_NODE, + Node.COMMENT_NODE, + Node.TEXT_NODE, + Node.CDATA_SECTION_NODE, + Node.ENTITY_REFERENCE_NODE) + + def __init__(self, name): + self.nodeName = name + self.childNodes = NodeList() + + def _get_nodeName(self): + return self.nodeName + + def appendChild(self, newChild): + raise xml.dom.NoModificationAllowedErr( + "entity reference nodes are read-only") + + def insertBefore(self, newChild, refChild): + raise xml.dom.NoModificationAllowedErr( + "entity reference nodes are read-only") + + def removeChild(self, oldChild): + raise xml.dom.NoModificationAllowedErr( + "entity reference nodes are read-only") + + def replaceChild(self, newChild, oldChild): + raise xml.dom.NoModificationAllowedErr( + "entity reference nodes are read-only") + + def writexml(self, writer, indent="", addindent="", newl=""): + writer.write("&%s;" % self.nodeName) + + class Notation(Identified, Childless, Node): nodeType = Node.NOTATION_NODE nodeValue = None @@ -1487,6 +1561,7 @@ def createDocument(self, namespaceURI, qualifiedName, doctype): return doc def createDocumentType(self, qualifiedName, publicId, systemId): + _check_name(qualifiedName) doctype = DocumentType(qualifiedName) doctype.publicId = publicId doctype.systemId = systemId @@ -1621,20 +1696,26 @@ def _get_strictErrorChecking(self): def _get_version(self): return self.version + def _check_new_child(self, newChild, oldChild=None): + Node._check_new_child(self, newChild, oldChild) + # A document can have only one element and only one document type. + if newChild.nodeType == Node.ELEMENT_NODE: + what = "document elements" + elif newChild.nodeType == Node.DOCUMENT_TYPE_NODE: + what = "document types" + else: + return + for node in self.childNodes: + if (node.nodeType == newChild.nodeType + and node is not newChild and node is not oldChild): + raise xml.dom.HierarchyRequestErr("two %s disallowed" % what) + def appendChild(self, node): - if node.nodeType not in self._child_node_types: - raise xml.dom.HierarchyRequestErr( - "%s cannot be child of %s" % (repr(node), repr(self))) + self._check_new_child(node) if node.parentNode is not None: - # This needs to be done before the next test since this - # may *be* the document element, in which case it should + # This may *be* the document element, in which case it should # end up re-ordered to the end. node.parentNode.removeChild(node) - - if node.nodeType == Node.ELEMENT_NODE \ - and self._get_documentElement(): - raise xml.dom.HierarchyRequestErr( - "two document elements disallowed") return Node.appendChild(self, node) def removeChild(self, oldChild): @@ -1687,6 +1768,7 @@ def createDocumentFragment(self): return d def createElement(self, tagName): + _check_name(tagName) e = Element(tagName) e.ownerDocument = self return e @@ -1707,29 +1789,39 @@ def createCDATASection(self, data): c.ownerDocument = self return c + def createEntityReference(self, name): + _check_name(name) + e = EntityReference(name) + e.ownerDocument = self + return e + def createComment(self, data): c = Comment(data) c.ownerDocument = self return c def createProcessingInstruction(self, target, data): + _check_name(target) p = ProcessingInstruction(target, data) p.ownerDocument = self return p def createAttribute(self, qName): + _check_name(qName) a = Attr(qName) a.ownerDocument = self a.value = "" return a def createElementNS(self, namespaceURI, qualifiedName): + _check_name(qualifiedName) prefix, localName = _nssplit(qualifiedName) e = Element(qualifiedName, namespaceURI, prefix) e.ownerDocument = self return e def createAttributeNS(self, namespaceURI, qualifiedName): + _check_name(qualifiedName) prefix, localName = _nssplit(qualifiedName) a = Attr(qualifiedName, namespaceURI, localName, prefix) a.ownerDocument = self @@ -1935,6 +2027,8 @@ def _clone_node(node, deep, newOwnerDocument): node.data) elif node.nodeType == Node.COMMENT_NODE: clone = newOwnerDocument.createComment(node.data) + elif node.nodeType == Node.ENTITY_REFERENCE_NODE: + clone = newOwnerDocument.createEntityReference(node.nodeName) elif node.nodeType == Node.ATTRIBUTE_NODE: clone = newOwnerDocument.createAttributeNS(node.namespaceURI, node.nodeName) diff --git a/Misc/NEWS.d/next/Library/2026-08-12-21-34-12.gh-issue-44871.Qw3Lm8.rst b/Misc/NEWS.d/next/Library/2026-08-12-21-34-12.gh-issue-44871.Qw3Lm8.rst new file mode 100644 index 000000000000000..d3adb49db69620f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-12-21-34-12.gh-issue-44871.Qw3Lm8.rst @@ -0,0 +1,19 @@ +:mod:`xml.dom.minidom` now conforms closer to the DOM Level 1 specification. +The factory methods now raise :exc:`~xml.dom.InvalidCharacterErr` +if the name is not a valid XML name. +Removing an absent attribute with :meth:`!Element.removeAttribute` or +:meth:`!Element.removeAttributeNS` no longer raises +:exc:`~xml.dom.NotFoundErr`, it has no effect as the DOM specifies. +Inserting or setting a node created by another document +now raises :exc:`~xml.dom.WrongDocumentErr`, +setting an attribute node which belongs to another element +now raises :exc:`~xml.dom.InuseAttributeErr`, +and inserting a node into itself or its descendant +now raises :exc:`~xml.dom.HierarchyRequestErr`. +A document can no longer have two element or two document type children, +and a document fragment can no longer have notation children. +Attributes defaulted in the DTD are no longer omitted when parsing, +and :attr:`!Attr.specified` now reports +whether the attribute was given in the start tag. +:class:`!EntityReference` nodes +and :meth:`!Document.createEntityReference` are now implemented. diff --git a/Misc/NEWS.d/next/Library/2026-08-12-21-34-12.gh-issue-44871.Rt7Nv2.rst b/Misc/NEWS.d/next/Library/2026-08-12-21-34-12.gh-issue-44871.Rt7Nv2.rst new file mode 100644 index 000000000000000..1d81ecea11c5d0a --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-12-21-34-12.gh-issue-44871.Rt7Nv2.rst @@ -0,0 +1,4 @@ +Parser objects of the :mod:`XML parser <xml.parsers.expat>` have a new method +:meth:`!GetSpecifiedAttributeCount`. +It tells how many of the attributes reported to :attr:`!StartElementHandler` +were given in the start tag rather than defaulted from the DTD. diff --git a/Modules/clinic/pyexpat.c.h b/Modules/clinic/pyexpat.c.h index 1a07726d303ecad..9e32bb079c1ea68 100644 --- a/Modules/clinic/pyexpat.c.h +++ b/Modules/clinic/pyexpat.c.h @@ -212,6 +212,30 @@ pyexpat_xmlparser_GetBase(PyObject *self, PyObject *Py_UNUSED(ignored)) return pyexpat_xmlparser_GetBase_impl((xmlparseobject *)self); } +PyDoc_STRVAR(pyexpat_xmlparser_GetSpecifiedAttributeCount__doc__, +"GetSpecifiedAttributeCount($self, /)\n" +"--\n" +"\n" +"Return the index just past the attributes given in the start tag.\n" +"\n" +"Attributes defaulted from the DTD follow the specified ones, so\n" +"attributes at lower indices in the list passed to\n" +"StartElementHandler were given in the start tag. Each attribute\n" +"takes two items in that list, its name and its value. Only\n" +"meaningful inside a StartElementHandler call."); + +#define PYEXPAT_XMLPARSER_GETSPECIFIEDATTRIBUTECOUNT_METHODDEF \ + {"GetSpecifiedAttributeCount", (PyCFunction)pyexpat_xmlparser_GetSpecifiedAttributeCount, METH_NOARGS, pyexpat_xmlparser_GetSpecifiedAttributeCount__doc__}, + +static PyObject * +pyexpat_xmlparser_GetSpecifiedAttributeCount_impl(xmlparseobject *self); + +static PyObject * +pyexpat_xmlparser_GetSpecifiedAttributeCount(PyObject *self, PyObject *Py_UNUSED(ignored)) +{ + return pyexpat_xmlparser_GetSpecifiedAttributeCount_impl((xmlparseobject *)self); +} + PyDoc_STRVAR(pyexpat_xmlparser_GetInputContext__doc__, "GetInputContext($self, /)\n" "--\n" @@ -839,4 +863,4 @@ pyexpat_ErrorString(PyObject *module, PyObject *arg) #ifndef PYEXPAT_XMLPARSER_SETALLOCTRACKERMAXIMUMAMPLIFICATION_METHODDEF #define PYEXPAT_XMLPARSER_SETALLOCTRACKERMAXIMUMAMPLIFICATION_METHODDEF #endif /* !defined(PYEXPAT_XMLPARSER_SETALLOCTRACKERMAXIMUMAMPLIFICATION_METHODDEF) */ -/*[clinic end generated code: output=270a0bfe3300e8a1 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=d7e59d139fe45487 input=a9049054013a1b77]*/ diff --git a/Modules/pyexpat.c b/Modules/pyexpat.c index 397a441f574fe45..fa8b0db60806233 100644 --- a/Modules/pyexpat.c +++ b/Modules/pyexpat.c @@ -1054,6 +1054,25 @@ pyexpat_xmlparser_GetBase_impl(xmlparseobject *self) return conv_string_to_unicode(XML_GetBase(self->itself)); } +/*[clinic input] +pyexpat.xmlparser.GetSpecifiedAttributeCount + +Return the index just past the attributes given in the start tag. + +Attributes defaulted from the DTD follow the specified ones, so +attributes at lower indices in the list passed to +StartElementHandler were given in the start tag. Each attribute +takes two items in that list, its name and its value. Only +meaningful inside a StartElementHandler call. +[clinic start generated code]*/ + +static PyObject * +pyexpat_xmlparser_GetSpecifiedAttributeCount_impl(xmlparseobject *self) +/*[clinic end generated code: output=f96b627de9393c0c input=4981c36cf99ebe9f]*/ +{ + return PyLong_FromLong(XML_GetSpecifiedAttributeCount(self->itself)); +} + /*[clinic input] @permit_long_summary pyexpat.xmlparser.GetInputContext @@ -1419,6 +1438,7 @@ static struct PyMethodDef xmlparse_methods[] = { PYEXPAT_XMLPARSER_PARSEFILE_METHODDEF PYEXPAT_XMLPARSER_SETBASE_METHODDEF PYEXPAT_XMLPARSER_GETBASE_METHODDEF + PYEXPAT_XMLPARSER_GETSPECIFIEDATTRIBUTECOUNT_METHODDEF PYEXPAT_XMLPARSER_GETINPUTCONTEXT_METHODDEF PYEXPAT_XMLPARSER_EXTERNALENTITYPARSERCREATE_METHODDEF PYEXPAT_XMLPARSER_SETPARAMENTITYPARSING_METHODDEF _______________________________________________ Python-checkins mailing list -- [email protected] To unsubscribe send an email to [email protected] https://mail.python.org/mailman3//lists/python-checkins.python.org Member address: [email protected]
