https://github.com/python/cpython/commit/5e0c502799f3324b8a0776cacd6a3af5b62879b5
commit: 5e0c502799f3324b8a0776cacd6a3af5b62879b5
branch: main
author: Cody Maloney <[email protected]>
committer: vstinner <[email protected]>
date: 2026-08-07T13:08:03+02:00
summary:

gh-81954: Warn when ZipFile is closed with unwritten data (#152354)

Bring `zipfile.ZipFile` into alignment with other buffered file writers
like `GzipFile` and `TextIOWrapper` by emitting a `ResourceWarning` if
there is unwritten data and it is closed implicitly.

ZipFile should be closed either by using it as a context manager or
explicitly calling `.close()`.

files:
A Misc/NEWS.d/next/Library/2026-06-26-16-15-55.gh-issue-81954.MfUjgS.rst
M Doc/library/zipfile.rst
M Lib/test/test_zipfile/_path/_test_params.py
M Lib/test/test_zipfile/_path/test_path.py
M Lib/test/test_zipfile/test_core.py
M Lib/zipfile/__init__.py

diff --git a/Doc/library/zipfile.rst b/Doc/library/zipfile.rst
index 98d2a5e5cdf00e2..65bc54e3856a945 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 writable, open :class:`zipfile.ZipFile` 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_params.py 
b/Lib/test/test_zipfile/_path/_test_params.py
index 00a9eaf2f99c1ae..8aaf7aa67a4dfb9 100644
--- a/Lib/test/test_zipfile/_path/_test_params.py
+++ b/Lib/test/test_zipfile/_path/_test_params.py
@@ -1,5 +1,6 @@
 import functools
 import types
+from contextlib import AbstractContextManager
 
 from ._itertools import always_iterable
 
@@ -9,6 +10,8 @@ def parameterize(names, value_groups):
     Decorate a test method to run it as a set of subtests.
 
     Modeled after pytest.parametrize.
+
+    Context Manager types are entered and exited.
     """
 
     def decorator(func):
@@ -17,6 +20,9 @@ def wrapped(self):
             for values in value_groups:
                 resolved = map(Invoked.eval, always_iterable(values))
                 params = dict(zip(always_iterable(names), resolved))
+                for value in params.values():
+                    if isinstance(value, AbstractContextManager):
+                        self.enterContext(value)
                 with self.subTest(**params):
                     func(self, **params)
 
diff --git a/Lib/test/test_zipfile/_path/test_path.py 
b/Lib/test/test_zipfile/_path/test_path.py
index e7931b6f394075d..b00491ab4cbdb77 100644
--- a/Lib/test/test_zipfile/_path/test_path.py
+++ b/Lib/test/test_zipfile/_path/test_path.py
@@ -1,4 +1,3 @@
-import contextlib
 import io
 import itertools
 import pathlib
@@ -83,12 +82,8 @@ def build_alpharep_fixture():
 
 
 class TestPath(unittest.TestCase):
-    def setUp(self):
-        self.fixtures = contextlib.ExitStack()
-        self.addCleanup(self.fixtures.close)
-
     def zipfile_ondisk(self, alpharep):
-        tmpdir = pathlib.Path(self.fixtures.enter_context(temp_dir()))
+        tmpdir = pathlib.Path(self.enterContext(temp_dir()))
         buffer = alpharep.fp
         alpharep.close()
         path = tmpdir / alpharep.filename
@@ -145,7 +140,7 @@ def test_open(self, alpharep):
 
     def test_open_encoding_utf16(self):
         in_memory_file = io.BytesIO()
-        zf = zipfile.ZipFile(in_memory_file, "w")
+        zf = self.enterContext(zipfile.ZipFile(in_memory_file, "w"))
         zf.writestr("path/16.txt", "This was utf-16".encode("utf-16"))
         zf.filename = "test_open_utf16.zip"
         root = zipfile.Path(zf)
@@ -160,7 +155,7 @@ def test_open_encoding_utf16(self):
 
     def test_open_encoding_errors(self):
         in_memory_file = io.BytesIO()
-        zf = zipfile.ZipFile(in_memory_file, "w")
+        zf = self.enterContext(zipfile.ZipFile(in_memory_file, "w"))
         zf.writestr("path/bad-utf8.bin", b"invalid utf-8: \xff\xff.")
         zf.filename = "test_read_text_encoding_errors.zip"
         root = zipfile.Path(zf)
@@ -204,7 +199,8 @@ def test_open_write(self):
         If the zipfile is open for write, it should be possible to
         write bytes or text to it.
         """
-        zf = zipfile.Path(zipfile.ZipFile(io.BytesIO(), mode='w'))
+        zip_file = self.enterContext(zipfile.ZipFile(io.BytesIO(), mode='w'))
+        zf = zipfile.Path(zip_file)
         with zf.joinpath('file.bin').open('wb') as strm:
             strm.write(b'binary contents')
         with zf.joinpath('file.txt').open('w', encoding="utf-8") as strm:
@@ -319,7 +315,7 @@ def test_mutability(self, alpharep):
     def huge_zipfile(self):
         """Create a read-only zipfile with a huge number of entries."""
         strm = io.BytesIO()
-        zf = zipfile.ZipFile(strm, "w")
+        zf = self.enterContext(zipfile.ZipFile(strm, "w"))
         for entry in map(str, range(self.HUGE_ZIPFILE_NUM_ENTRIES)):
             zf.writestr(entry, entry)
         zf.mode = 'r'
@@ -530,7 +526,8 @@ def test_glob_chars(self, alpharep):
         ]
 
     def test_glob_empty(self):
-        root = zipfile.Path(zipfile.ZipFile(io.BytesIO(), 'w'))
+        zip_file = self.enterContext(zipfile.ZipFile(io.BytesIO(), 'w'))
+        root = zipfile.Path(zip_file)
         with self.assertRaises(ValueError):
             root.glob('')
 
@@ -614,7 +611,7 @@ def test_malformed_paths(self):
         Paths with dots are treated like regular files.
         """
         data = io.BytesIO()
-        zf = zipfile.ZipFile(data, "w")
+        zf = self.enterContext(zipfile.ZipFile(data, "w"))
         zf.writestr("/one-slash.txt", b"content")
         zf.writestr("//two-slash.txt", b"content")
         zf.writestr("../parent.txt", b"content")
@@ -632,7 +629,7 @@ def test_unsupported_names(self):
         in the zip file.
         """
         data = io.BytesIO()
-        zf = zipfile.ZipFile(data, "w")
+        zf = self.enterContext(zipfile.ZipFile(data, "w"))
         zf.writestr("path?", b"content")
         zf.writestr("V: NMS.flac", b"fLaC...")
         zf.filename = ''
@@ -647,7 +644,7 @@ def test_backslash_not_separator(self):
         In a zip file, backslashes are not separators.
         """
         data = io.BytesIO()
-        zf = zipfile.ZipFile(data, "w")
+        zf = self.enterContext(zipfile.ZipFile(data, "w"))
         zf.writestr(DirtyZipInfo("foo\\bar")._for_archive(zf), b"content")
         zf.filename = ''
         root = zipfile.Path(zf)
diff --git a/Lib/test/test_zipfile/test_core.py 
b/Lib/test/test_zipfile/test_core.py
index cd498ba13e6f461..83f2eef6b6f8ab6 100644
--- a/Lib/test/test_zipfile/test_core.py
+++ b/Lib/test/test_zipfile/test_core.py
@@ -26,12 +26,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"
@@ -4058,6 +4059,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'
@@ -5510,10 +5533,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 418933a2e8d9e87..6c353b4ff850626 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,11 @@ 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 writable ZipFile is implicitly closed.
+        # 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'):
+            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 000000000000000..a5cca7cb6301040
--- /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 writable, open :class:`zipfile.ZipFile` now emits a
+:exc:`ResourceWarning`. Use as a :term:`context manager`
+or call :meth:`~zipfile.ZipFile.close` explicitly.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to