diff --git a/Doc/library/gzip.rst b/Doc/library/gzip.rst index f24e73517e5767..1a3b094b590447 100644 --- a/Doc/library/gzip.rst +++ b/Doc/library/gzip.rst @@ -105,6 +105,11 @@ The module defines the following items: If *mtime* is omitted or ``None``, the current time is used. Use *mtime* = 0 to generate a compressed stream that does not depend on creation time. + .. versionchanged:: next + The *mtime* parameter can now be a :class:`~datetime.datetime` object as well + as a :class:`float`. If not :ref:`timezone aware ` then a + :class:`ValueError` will be raised. + See below for the :attr:`mtime` attribute that is set when decompressing. Calling a :class:`GzipFile` object's :meth:`!close` method does not close @@ -209,6 +214,11 @@ The module defines the following items: For the previous behaviour of using the current time, pass ``None`` to *mtime*. + .. versionchanged:: next + The *mtime* parameter can now be a :class:`~datetime.datetime` object as well + as a :class:`float`. If not :ref:`timezone aware ` then a + :class:`ValueError` will be raised. + .. function:: decompress(data) Decompress the *data*, returning a :class:`bytes` object containing the diff --git a/Lib/gzip.py b/Lib/gzip.py index 1a3c82ce7e0711..0b3c3a6a3236e7 100644 --- a/Lib/gzip.py +++ b/Lib/gzip.py @@ -5,6 +5,7 @@ # based on Andrew Kuchling's minigzip.py distributed with the zlib module +from datetime import datetime, timezone import struct, sys, time, os import zlib import builtins @@ -224,6 +225,8 @@ def __init__(self, filename=None, mode=None, -zlib.MAX_WBITS, zlib.DEF_MEM_LEVEL, 0) + if isinstance(mtime, datetime) and mtime.tzinfo is None: + raise ValueError("Refusing to write naive datetime to Gzip header") self._write_mtime = mtime self._buffer_size = _WRITE_BUFFER_SIZE self._buffer = io.BufferedWriter(_WriteBufferStream(self), @@ -239,7 +242,8 @@ def __init__(self, filename=None, mode=None, @property def mtime(self): """Last modification time read from stream, or None""" - return self._buffer.raw._last_mtime + mtime = self._buffer.raw._last_mtime + return int(mtime.timestamp()) if mtime is not None else None def __repr__(self): s = repr(self.fileobj) @@ -278,6 +282,8 @@ def _write_gzip_header(self, compresslevel): mtime = self._write_mtime if mtime is None: mtime = time.time() + elif isinstance(mtime, datetime): + mtime = mtime.timestamp() write32u(self.fileobj, int(mtime)) if compresslevel == _COMPRESS_LEVEL_BEST: xfl = b'\002' @@ -479,7 +485,7 @@ def _read_gzip_header(fp): break if flag & FHCRC: _read_exact(fp, 2) # Read & discard the 16-bit header CRC - return last_mtime + return datetime.fromtimestamp(last_mtime, tz=timezone.utc) class _GzipReader(_compression.DecompressReader): @@ -591,6 +597,10 @@ def compress(data, compresslevel=_COMPRESS_LEVEL_BEST, *, mtime=0): gzip_data = zlib.compress(data, level=compresslevel, wbits=31) if mtime is None: mtime = time.time() + elif isinstance(mtime, datetime): + if mtime.tzinfo is None: + raise ValueError("Refusing to write naive datetime to Gzip header") + mtime = mtime.timestamp() # Reuse gzip header created by zlib, replace mtime and OS byte for # consistency. header = struct.pack("<4sLBB", gzip_data, int(mtime), gzip_data[8], 255) diff --git a/Lib/test/test_gzip.py b/Lib/test/test_gzip.py index bf6e1703db8451..b5bb88f4c28ba2 100644 --- a/Lib/test/test_gzip.py +++ b/Lib/test/test_gzip.py @@ -8,6 +8,7 @@ import struct import sys import unittest +from datetime import datetime, timezone from subprocess import PIPE, Popen from test.support import import_helper from test.support import os_helper @@ -316,6 +317,24 @@ def test_mtime(self): self.assertEqual(dataRead, data1) self.assertEqual(fRead.mtime, mtime) + def test_mtime_as_datetime(self): + mtime = datetime(1973, 11, 29, 21, 33, 9, tzinfo=timezone.utc) + with gzip.GzipFile(self.filename, 'w', mtime = mtime) as fWrite: + fWrite.write(data1) + with gzip.GzipFile(self.filename) as fRead: + self.assertTrue(hasattr(fRead, 'mtime')) + self.assertIsNone(fRead.mtime) + dataRead = fRead.read() + self.assertEqual(dataRead, data1) + self.assertEqual(fRead.mtime, int(mtime.timestamp())) + + def test_mtime_as_datetime_no_timezone(self): + mtime = datetime(1973, 11, 29, 21, 33, 9) + self.assertIsNone(mtime.tzinfo) + with self.assertRaises(ValueError): + with gzip.GzipFile(self.filename, 'w', mtime = mtime) as fWrite: + fWrite.write(data1) + def test_metadata(self): mtime = 123456789 @@ -713,6 +732,26 @@ def test_compress_mtime(self): f.read(1) # to set mtime attribute self.assertEqual(f.mtime, mtime) + def test_compress_mtime_as_datetime(self): + mtime = datetime(1973, 11, 29, 21, 33, 9, tzinfo=timezone.utc) + for data in [data1, data2]: + for args in [(), (1,), (6,), (9,)]: + with self.subTest(data=data, args=args): + datac = gzip.compress(data, *args, mtime=mtime) + self.assertEqual(type(datac), bytes) + with gzip.GzipFile(fileobj=io.BytesIO(datac), mode="rb") as f: + f.read(1) # to set mtime attribute + self.assertEqual(f.mtime, int(mtime.timestamp())) + + def test_compress_mtime_as_datetime_no_timezone(self): + mtime = datetime(1973, 11, 29, 21, 33, 9) + self.assertIsNone(mtime.tzinfo) + for data in [data1, data2]: + for args in [(), (1,), (6,), (9,)]: + with self.subTest(data=data, args=args): + with self.assertRaises(ValueError): + gzip.compress(data, *args, mtime=mtime) + def test_compress_mtime_default(self): # test for gh-125260 datac = gzip.compress(data1, mtime=0) diff --git a/Misc/NEWS.d/next/Library/2025-01-07-15-31-37.gh-issue-128584.RNjQh2.rst b/Misc/NEWS.d/next/Library/2025-01-07-15-31-37.gh-issue-128584.RNjQh2.rst new file mode 100644 index 00000000000000..1dd6925960994f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-01-07-15-31-37.gh-issue-128584.RNjQh2.rst @@ -0,0 +1 @@ +Allow the *mtime* parameters in :func:`gzip.compress` and :class:`gzip.GzipFile` to be :class:`datetime.datetime` objects.