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]