diff --git a/Doc/library/xml.dom.pulldom.rst b/Doc/library/xml.dom.pulldom.rst index 52340ffe92eb857..e886e08f7b3400d 100644 --- a/Doc/library/xml.dom.pulldom.rst +++ b/Doc/library/xml.dom.pulldom.rst @@ -41,12 +41,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 a constant and can be one of: @@ -114,6 +114,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 a tuple containing *event* and the current *node* as @@ -141,3 +146,15 @@ DOMEventStream Objects print(node.toxml()) .. method:: DOMEventStream.reset() + + .. method:: clear() + + Release references to the parser, the stream and the DOM builder, + and close the stream if it was opened by :func:`parse`. Streams + provided by the caller are not closed. It is safe to call this + method more than once. The event stream cannot be used after + calling this method. + + .. versionchanged:: next + This method now closes the underlying stream if it was opened + by :func:`parse`. diff --git a/Lib/test/test_pulldom.py b/Lib/test/test_pulldom.py index 3c8ed251acaa4d8..7261b5a856919df 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 913141cd7ef3ceb..91aa97546658f98 100644 --- a/Lib/xml/dom/pulldom.py +++ b/Lib/xml/dom/pulldom.py @@ -202,10 +202,11 @@ def fatalError(self, exception): raise exception class DOMEventStream: - 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() @@ -225,6 +226,12 @@ def __next__(self): def __iter__(self): return self + def __enter__(self): + return self + + def __exit__(self, *args): + self.clear() + def expandNode(self, node): event = self.getEvent() parents = [node] @@ -274,9 +281,15 @@ def _emit(self): return rc def clear(self): - """clear(): Explicitly release parsing objects""" - self.pulldom.clear() - del self.pulldom + """Release the parsing objects. + + 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 @@ -320,11 +333,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): from io import StringIO 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 000000000000000..301b33596c419f0 --- /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.