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 <283361+jorendorff@users.noreply.github.com>
diff --git a/Doc/library/pyexpat.rst b/Doc/library/pyexpat.rst
index c88411c..abd68c5 100644
--- a/Doc/library/pyexpat.rst
+++ b/Doc/library/pyexpat.rst
@@ -172,6 +172,20 @@
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 ded3859..efc81f3 100644
--- a/Doc/library/xml.dom.minidom.rst
+++ b/Doc/library/xml.dom.minidom.rst
@@ -261,23 +261,31 @@
: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 9757338..250b9be 100644
--- a/Doc/library/xml.dom.rst
+++ b/Doc/library/xml.dom.rst
@@ -232,6 +232,8 @@
*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 @@
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 @@
*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 @@
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 @@
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 @@
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 @@
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 @@
*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 @@
: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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 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 a1a8415..f3ddae7 100644
--- a/Doc/whatsnew/3.16.rst
+++ b/Doc/whatsnew/3.16.rst
@@ -630,6 +630,24 @@
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 @@
: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 46249e5..3735a60 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 7dd667b..d56b2dd 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 16b33b9..5fd3911 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 0000000..d3adb49
--- /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 0000000..1d81ece
--- /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 1a07726..9e32bb0 100644
--- a/Modules/clinic/pyexpat.c.h
+++ b/Modules/clinic/pyexpat.c.h
@@ -212,6 +212,30 @@
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 @@
#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 397a441..fa8b0db 100644
--- a/Modules/pyexpat.c
+++ b/Modules/pyexpat.c
@@ -1055,6 +1055,25 @@ pyexpat_xmlparser_GetBase_impl(xmlparseobject *self)
}
/*[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