From feabab3a5ef0e4a4c1400d2b0b9aacd85af7dd0c Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 12 Aug 2026 21:56:59 +0300 Subject: [PATCH 1/4] gh-44871: Add pyexpat GetSpecifiedAttributeCount() It tells how many of the attributes reported to StartElementHandler were given in the start tag rather than defaulted from the DTD. --- Doc/library/pyexpat.rst | 14 ++++++++++ Doc/whatsnew/3.16.rst | 6 +++++ ...6-08-12-21-34-12.gh-issue-44871.Rt7Nv2.rst | 4 +++ Modules/clinic/pyexpat.c.h | 26 ++++++++++++++++++- Modules/pyexpat.c | 20 ++++++++++++++ 5 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-12-21-34-12.gh-issue-44871.Rt7Nv2.rst diff --git a/Doc/library/pyexpat.rst b/Doc/library/pyexpat.rst index c88411ce0b7b91..abd68c5006bd47 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/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index b017535b96979d..1d659812a8ebbf 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -613,6 +613,12 @@ xml instead of failing later, when encountering non-ASCII data. (Contributed by Serhiy Storchaka in :gh:`62259`.) +* Add :meth:`!GetSpecifiedAttributeCount` method + to the :mod:`XML parser ` 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 ------- 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 00000000000000..1da1d1a2fee6df --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-12-21-34-12.gh-issue-44871.Rt7Nv2.rst @@ -0,0 +1,4 @@ +:mod:`pyexpat` parser objects 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 1a07726d303eca..9e32bb079c1ea6 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 397a441f574fe4..fa8b0db6080623 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 From f59bfabb47756a6e92dae852d298be40320dfa27 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 12 Aug 2026 21:56:59 +0300 Subject: [PATCH 2/4] gh-44871: Improve DOM Level 1 conformance of xml.dom.minidom * The factory methods now raise InvalidCharacterErr if the name is not a valid XML name. * appendChild(), insertBefore() and replaceChild() now raise WrongDocumentErr if the new child was created by other document, and HierarchyRequestErr if it is the node itself or its ancestor. * 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. * EntityReference nodes and Document.createEntityReference() are now implemented. --- Doc/library/xml.dom.minidom.rst | 9 +- Doc/library/xml.dom.rst | 27 +++++ Doc/whatsnew/3.16.rst | 21 ++++ Lib/test/test_minidom.py | 111 ++++++++++++++++++ Lib/xml/dom/expatbuilder.py | 5 +- Lib/xml/dom/minidom.py | 107 +++++++++++++++-- ...6-08-12-21-34-12.gh-issue-44871.Qw3Lm8.rst | 12 ++ 7 files changed, 275 insertions(+), 17 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-12-21-34-12.gh-issue-44871.Qw3Lm8.rst diff --git a/Doc/library/xml.dom.minidom.rst b/Doc/library/xml.dom.minidom.rst index 1a5291d018ac70..fab22e9844ed5e 100644 --- a/Doc/library/xml.dom.minidom.rst +++ b/Doc/library/xml.dom.minidom.rst @@ -255,11 +255,14 @@ 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:`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 34e58dcad93012..a79c38ee5ddff7 100644 --- a/Doc/library/xml.dom.rst +++ b/Doc/library/xml.dom.rst @@ -531,6 +531,14 @@ 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. + + .. versionadded:: next + + .. method:: Document.createComment(data) Create and return a comment node containing the data passed as a parameter. As @@ -800,6 +808,25 @@ Represents a processing instruction in the XML document; this inherits from the character. +.. _dom-entityreference-objects: + +EntityReference Objects +^^^^^^^^^^^^^^^^^^^^^^^ + +.. 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:`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: Exceptions diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 1d659812a8ebbf..ae3eefeadaf09e 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -613,6 +613,16 @@ 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 a node created by other document + or making a node a descendant of itself, + 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 ` objects. It tells how many of the reported attributes were given in the start tag @@ -834,6 +844,17 @@ 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 a node created by other document, + and :exc:`~xml.dom.HierarchyRequestErr` for inserting a node into itself + or its descendant. + Such operations formerly succeeded + and produced an invalid document or an endless loop. + 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 46249e5138aed5..52f9adec90e02c 100644 --- a/Lib/test/test_minidom.py +++ b/Lib/test/test_minidom.py @@ -1784,5 +1784,116 @@ def test_cdata_parsing(self): dom2 = parseString(dom1.toprettyxml()) self.checkWholeText(dom2.getElementsByTagName('node')[0].firstChild, '') + def testInvalidCharacterErr(self): + doc = parseString("") + impl = getDOMImplementation() + for name in ("", "bad name", "1st", "-x", ".x", "a") + other = parseString("") + 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("") + 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 testAttrSpecified(self): + doc = parseString("" + " " + "]>") + 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("") + 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(), "&ent;") + # 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("") + 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 7dd667bf3fbe04..d56b2ddfdb2569 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 16b33b90184dc5..a09e1afb343d57 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,30 @@ def _get_lastChild(self): if self.childNodes: return self.childNodes[-1] + def _check_new_child(self, newChild): + # 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)) + if _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 +133,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 +147,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) if newChild.parentNode is not None: newChild.parentNode.removeChild(newChild) try: @@ -282,6 +294,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 @@ -758,6 +786,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 +800,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 @@ -1416,6 +1446,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 +1556,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 @@ -1622,9 +1692,7 @@ def _get_version(self): return self.version 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 @@ -1687,6 +1755,7 @@ def createDocumentFragment(self): return d def createElement(self, tagName): + _check_name(tagName) e = Element(tagName) e.ownerDocument = self return e @@ -1707,29 +1776,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 +2014,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 00000000000000..2fe6bc5bada3b9 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-12-21-34-12.gh-issue-44871.Qw3Lm8.rst @@ -0,0 +1,12 @@ +: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. +Inserting a node created by other document +now raises :exc:`~xml.dom.WrongDocumentErr`, +and inserting a node into itself or its descendant +now raises :exc:`~xml.dom.HierarchyRequestErr`. +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. From 9de89b7c5adc119e92a418af6c7d662ab059e8b2 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Thu, 13 Aug 2026 09:22:14 +0300 Subject: [PATCH 3/4] gh-44871: Only walk ancestors when the new child has children A node without children cannot be an ancestor, so testing this first keeps appending leaf nodes linear in the depth of the tree. Also fix references which do not resolve on main. --- Doc/library/xml.dom.minidom.rst | 2 +- Doc/library/xml.dom.rst | 4 ++-- Lib/xml/dom/minidom.py | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Doc/library/xml.dom.minidom.rst b/Doc/library/xml.dom.minidom.rst index fab22e9844ed5e..4b8fbcefccc968 100644 --- a/Doc/library/xml.dom.minidom.rst +++ b/Doc/library/xml.dom.minidom.rst @@ -259,7 +259,7 @@ This reflects information in the XML document that is not of general utility to most DOM users. .. versionchanged:: next - :class:`EntityReference` is now implemented. + :class:`~xml.dom.EntityReference` is now implemented. Note that the parser expands entity references, so they only occur in a document if created explicitly. diff --git a/Doc/library/xml.dom.rst b/Doc/library/xml.dom.rst index a79c38ee5ddff7..dc6c8a0a3b7c64 100644 --- a/Doc/library/xml.dom.rst +++ b/Doc/library/xml.dom.rst @@ -816,8 +816,8 @@ EntityReference Objects .. 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:`nodeName`. + 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. diff --git a/Lib/xml/dom/minidom.py b/Lib/xml/dom/minidom.py index a09e1afb343d57..8a9da56980b9c7 100644 --- a/Lib/xml/dom/minidom.py +++ b/Lib/xml/dom/minidom.py @@ -93,7 +93,9 @@ def _check_new_child(self, newChild): if newChild is self: raise xml.dom.HierarchyRequestErr( "%s cannot be child of itself" % repr(self)) - if _is_ancestor(newChild, 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))) From 76726398788efbea94716985bb90c7858aa375fb Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Thu, 13 Aug 2026 10:14:51 +0300 Subject: [PATCH 4/4] gh-44871: Fix the module reference in the NEWS entry The documented module is xml.parsers.expat, there is no target for pyexpat. --- .../next/Library/2026-08-12-21-34-12.gh-issue-44871.Rt7Nv2.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 1da1d1a2fee6df..1d81ecea11c5d0 100644 --- 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 @@ -1,4 +1,4 @@ -:mod:`pyexpat` parser objects have a new method +Parser objects of the :mod:`XML parser ` 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.