diff --git a/Lib/compression/zstd/_zstdfile.py b/Lib/compression/zstd/_zstdfile.py index 26d7c9006375fb6..d82f93c7abaca25 100644 --- a/Lib/compression/zstd/_zstdfile.py +++ b/Lib/compression/zstd/_zstdfile.py @@ -54,6 +54,7 @@ def __init__(self, file, /, mode='r', *, self._close_fp = False self._mode = _MODE_CLOSED self._buffer = None + self._write_started = False if not isinstance(mode, str): raise ValueError('mode must be a str') @@ -68,6 +69,9 @@ def __init__(self, file, /, mode='r', *, if level is not None and not isinstance(level, int): raise TypeError('level must be int or None') self._mode = _MODE_WRITE + # Do not add an empty frame when closing an existing archive in + # append mode without writing anything. + self._write_started = mode == 'a' self._compressor = ZstdCompressor(level=level, options=options, zstd_dict=zstd_dict) self._pos = 0 @@ -131,6 +135,7 @@ def write(self, data, /): length = _nbytes(data) compressed = self._compressor.compress(data) + self._write_started = True self._fp.write(compressed) self._pos += length return length @@ -153,10 +158,11 @@ def flush(self, mode=FLUSH_BLOCK): raise ValueError('Invalid mode argument, expected either ' 'ZstdFile.FLUSH_FRAME or ' 'ZstdFile.FLUSH_BLOCK') - if self._compressor.last_mode == mode: + if self._compressor.last_mode == mode and self._write_started: return # Flush zstd block/frame, and write. data = self._compressor.flush(mode) + self._write_started = True self._fp.write(data) if hasattr(self._fp, 'flush'): self._fp.flush() diff --git a/Lib/test/test_zstd.py b/Lib/test/test_zstd.py index 9225c42e0e711aa..ed25765d21d5345 100644 --- a/Lib/test/test_zstd.py +++ b/Lib/test/test_zstd.py @@ -2120,16 +2120,16 @@ def test_write_empty_frame(self): self.assertNotEqual(c.flush(c.FLUSH_FRAME), b'') self.assertNotEqual(c.flush(c.FLUSH_FRAME), b'') - # don't generate empty content frame + # generate an empty content frame when the file is closed bo = io.BytesIO() with ZstdFile(bo, 'w') as f: pass - self.assertEqual(bo.getvalue(), b'') + self.assertEqual(decompress(bo.getvalue()), b'') bo = io.BytesIO() with ZstdFile(bo, 'w') as f: f.flush(f.FLUSH_FRAME) - self.assertEqual(bo.getvalue(), b'') + self.assertEqual(decompress(bo.getvalue()), b'') # if .write(b''), generate empty content frame bo = io.BytesIO() diff --git a/Misc/NEWS.d/next/Library/2026-08-07-00-00-00.gh-issue-155286.empty-zstd-file.rst b/Misc/NEWS.d/next/Library/2026-08-07-00-00-00.gh-issue-155286.empty-zstd-file.rst new file mode 100644 index 000000000000000..08c3ada3bd38eab --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-07-00-00-00.gh-issue-155286.empty-zstd-file.rst @@ -0,0 +1,2 @@ +Fix :class:`~compression.zstd.ZstdFile` creating an invalid zero-byte archive +when an output file is closed without any writes.