diff --git a/Doc/library/xml.dom.pulldom.rst b/Doc/library/xml.dom.pulldom.rst index 85841d9d1951b8..c86f23786faa9f 100644 --- a/Doc/library/xml.dom.pulldom.rst +++ b/Doc/library/xml.dom.pulldom.rst @@ -46,12 +46,12 @@ Example:: from xml.dom import pulldom - doc = pulldom.parse('sales_items.xml') - for event, node in doc: - if event == pulldom.START_ELEMENT and node.tagName == 'item': - if int(node.getAttribute('price')) > 50: - doc.expandNode(node) - print(node.toxml()) + with pulldom.parse('sales_items.xml') as doc: + for event, node in doc: + if event == pulldom.START_ELEMENT and node.tagName == 'item': + if int(node.getAttribute('price')) > 50: + doc.expandNode(node) + print(node.toxml()) ``event`` is one of the following constants, and ``node`` is the node which the event is about. @@ -163,6 +163,11 @@ DOMEventStream Objects .. versionchanged:: 3.11 Support for :meth:`~object.__getitem__` method has been removed. + .. versionchanged:: next + :class:`DOMEventStream` can now be used as a :term:`context manager`. + On exit, :meth:`~DOMEventStream.clear` is called, closing file + handles opened by :func:`parse`. + .. method:: getEvent() Return the next ``(event, node)`` tuple, @@ -196,4 +201,11 @@ DOMEventStream Objects .. method:: clear() Release the parser and the document. - The stream is not closed, and the object can no longer be used. + The stream is closed if it was opened by :func:`parse`; + streams provided by the caller are not closed. + The object can no longer be used, but it is safe to call + this method more than once. + + .. versionchanged:: next + This method now closes the stream if it was opened by + :func:`parse`. diff --git a/Lib/test/test_pulldom.py b/Lib/test/test_pulldom.py index 3c8ed251acaa4d..7261b5a856919d 100644 --- a/Lib/test/test_pulldom.py +++ b/Lib/test/test_pulldom.py @@ -33,13 +33,39 @@ def test_parse(self): # Test with a filename: handler = pulldom.parse(tstfile) - self.addCleanup(handler.stream.close) + self.addCleanup(handler.clear) list(handler) # Test with a file object: with open(tstfile, "rb") as fin: list(pulldom.parse(fin)) + def test_context_manager_closes_owned_stream(self): + with pulldom.parse(tstfile) as events: + stream = events.stream + list(events) + self.assertTrue(stream.closed) + + def test_context_manager_does_not_close_user_stream(self): + with open(tstfile, 'rb') as f: + with pulldom.parse(f) as events: + list(events) + self.assertFalse(f.closed) + + def test_clear_closes_owned_stream(self): + events = pulldom.parse(tstfile) + stream = events.stream + events.clear() + self.assertTrue(stream.closed) + events.clear() # should not raise + + def test_clear_does_not_close_user_stream(self): + with open(tstfile, 'rb') as f: + events = pulldom.parse(f) + list(events) + events.clear() + self.assertFalse(f.closed) + def test_parse_semantics(self): """Test DOMEventStream parsing semantics.""" diff --git a/Lib/xml/dom/pulldom.py b/Lib/xml/dom/pulldom.py index 9d33d83458e39c..2f6f21a10af546 100644 --- a/Lib/xml/dom/pulldom.py +++ b/Lib/xml/dom/pulldom.py @@ -210,10 +210,11 @@ def fatalError(self, exception): class DOMEventStream: """Stream of the pull parser events.""" - def __init__(self, stream, parser, bufsize): + def __init__(self, stream, parser, bufsize, _owns_stream=False): self.stream = stream self.parser = parser self.bufsize = bufsize + self._owns_stream = _owns_stream if not hasattr(self.parser, 'feed'): self.getEvent = self._slurp self.reset() @@ -234,6 +235,12 @@ def __next__(self): def __iter__(self): return self + def __enter__(self): + return self + + def __exit__(self, *args): + self.clear() + def expandNode(self, node): """Expand all children of the node into the node.""" event = self.getEvent() @@ -285,9 +292,15 @@ def _emit(self): return rc def clear(self): - """Release the parser and the document.""" - self.pulldom.clear() - del self.pulldom + """Release the parser and the document. + + The stream is closed if it was opened by parse(). + """ + if self._owns_stream and self.stream is not None: + self.stream.close() + if self.pulldom is not None: + self.pulldom.clear() + self.pulldom = None self.parser = None self.stream = None @@ -333,11 +346,13 @@ def parse(stream_or_string, parser=None, bufsize=None): bufsize = default_bufsize if isinstance(stream_or_string, str): stream = open(stream_or_string, 'rb') + owns_stream = True else: stream = stream_or_string + owns_stream = False if not parser: parser = xml.sax.make_parser() - return DOMEventStream(stream, parser, bufsize) + return DOMEventStream(stream, parser, bufsize, _owns_stream=owns_stream) def parseString(string, parser=None): """Return a DOMEventStream for the given string.""" diff --git a/Misc/NEWS.d/next/Library/2026-04-12-11-14-40.gh-issue-148428.kP9qWx.rst b/Misc/NEWS.d/next/Library/2026-04-12-11-14-40.gh-issue-148428.kP9qWx.rst new file mode 100644 index 00000000000000..301b33596c419f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-04-12-11-14-40.gh-issue-148428.kP9qWx.rst @@ -0,0 +1,5 @@ +:class:`xml.dom.pulldom.DOMEventStream` can now be used as a :term:`context +manager`, and its :meth:`~xml.dom.pulldom.DOMEventStream.clear` method now +closes the file opened by :func:`xml.dom.pulldom.parse`, fixing a resource +leak. This also fixes the file handle leak in :func:`xml.dom.minidom.parse` +when a custom *parser* is used.