diff --git a/Doc/library/zipfile.rst b/Doc/library/zipfile.rst index 98d2a5e5cdf00e..3470c2f9211f74 100644 --- a/Doc/library/zipfile.rst +++ b/Doc/library/zipfile.rst @@ -285,6 +285,10 @@ ZipFile objects Added support for specifying member name encoding for reading metadata in the zipfile's directory and file headers. + .. versionchanged:: next + Deleting a :class:`zipfile.ZipFile` which contains unwritten data before + it is closed now emits a :exc:`ResourceWarning`. Use as a + :term:`context manager` or call :meth:`~zipfile.ZipFile.close` explicitly. .. method:: ZipFile.close() diff --git a/Lib/test/test_zipfile/_path/test_path.py b/Lib/test/test_zipfile/_path/test_path.py index e7931b6f394075..97e1377743644a 100644 --- a/Lib/test/test_zipfile/_path/test_path.py +++ b/Lib/test/test_zipfile/_path/test_path.py @@ -6,6 +6,7 @@ import stat import sys import unittest +import warnings import zipfile import zipfile._path @@ -86,6 +87,12 @@ class TestPath(unittest.TestCase): def setUp(self): self.fixtures = contextlib.ExitStack() self.addCleanup(self.fixtures.close) + # These tests use zipfiles as fixtures which do not get closed. Ignore + # the ResourceWarning. + self.fixtures.enter_context(warnings.catch_warnings()) + warnings.filterwarnings( + 'ignore', category=ResourceWarning, message='unclosed ZipFile' + ) def zipfile_ondisk(self, alpharep): tmpdir = pathlib.Path(self.fixtures.enter_context(temp_dir())) diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py index 4f20209927e7b3..cc3c0833cd2d9c 100644 --- a/Lib/test/test_zipfile/test_core.py +++ b/Lib/test/test_zipfile/test_core.py @@ -25,12 +25,13 @@ from test.support import ( findfile, requires_zlib, requires_bz2, requires_lzma, requires_zstd, captured_stdout, captured_stderr, requires_subprocess, - cpython_only + cpython_only, gc_collect ) from test.support.os_helper import ( TESTFN, unlink, rmtree, temp_dir, temp_cwd, fd_count, FakePath ) from test.support.import_helper import ensure_lazy_imports +from test.support.warnings_helper import check_no_resource_warning TESTFN2 = TESTFN + "2" @@ -4051,6 +4052,28 @@ def test_close_on_exception(self): except zipfile.BadZipFile: self.assertIsNone(zipfp2.fp, 'zipfp is not closed') + def test_garbage_collection(self): + # gh-81954: Warn if a writable zipfile is closed by GC. + with self.assertWarns(ResourceWarning): + zipfile.ZipFile(io.BytesIO(), "w") + gc_collect() + + # Only warn if there is possible data loss. + # Properly closed via context manager. + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("f.txt", b"data") + + with check_no_resource_warning(self): + # Read mode: No possible data loss. + zipfile.ZipFile(buf, "r") + + # Write with manual explicit close: No pending data. + zf = zipfile.ZipFile(io.BytesIO(), "w") + zf.writestr("f.txt", b"data") + zf.close() + del zf + def test_unsupported_version(self): # File has an extract_version of 120 data = (b'PK\x03\x04x\x00\x00\x00\x00\x00!p\xa1@\x00\x00\x00\x00\x00\x00' @@ -5503,10 +5526,10 @@ def test_root_folder_in_zipfile(self): the zip file, this is a strange behavior, but we should support it. """ in_memory_file = io.BytesIO() - zf = zipfile.ZipFile(in_memory_file, "w") - zf.mkdir('/') - zf.writestr('./a.txt', 'aaa') - zf.extractall(TESTFN2) + with zipfile.ZipFile(in_memory_file, "w") as zf: + zf.mkdir('/') + zf.writestr('./a.txt', 'aaa') + zf.extractall(TESTFN2) def tearDown(self): rmtree(TESTFN2) diff --git a/Lib/zipfile/__init__.py b/Lib/zipfile/__init__.py index 418933a2e8d9e8..9c5b409b38b569 100644 --- a/Lib/zipfile/__init__.py +++ b/Lib/zipfile/__init__.py @@ -12,6 +12,7 @@ import sys import threading import time +lazy import warnings try: import zlib # We may need its compression method @@ -2616,6 +2617,13 @@ def mkdir(self, zinfo_or_directory_name, mode=511): def __del__(self): """Call the "close()" method in case the user forgot.""" + # gh-81954: Warn if ZipFile is implicitly closed with unwritten end + # records. GC cleanup order is non-deterministic and can result in data + # loss. + if (self.fp is not None and self.mode in ('w', 'x', 'a') + and self._didModify): + warnings.warn(f"unclosed ZipFile {self!r}", + ResourceWarning, source=self, stacklevel=2) self.close() def close(self): diff --git a/Misc/NEWS.d/next/Library/2026-06-26-16-15-55.gh-issue-81954.MfUjgS.rst b/Misc/NEWS.d/next/Library/2026-06-26-16-15-55.gh-issue-81954.MfUjgS.rst new file mode 100644 index 00000000000000..9d0de6ee1a9a15 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-26-16-15-55.gh-issue-81954.MfUjgS.rst @@ -0,0 +1,3 @@ +Deleting a :class:`zipfile.ZipFile` which contains unwritten data before it +is closed now emits a :exc:`ResourceWarning`. Use as a :term:`context manager` +or call :meth:`~zipfile.ZipFile.close` explicitly.