From 9dcef46bc306d00401cddfc98b7cac038407be6b Mon Sep 17 00:00:00 2001 From: Dmitry Voropaev Date: Thu, 24 Sep 2026 18:25:36 +0300 Subject: [PATCH 1/3] gh-88661: Derive the gzip FNAME field like gunzip does The FNAME field of the gzip header is meant to hold the name the file is expected to have after decompression, but gzip and tarfile only stripped a literal ".gz" suffix. An archive created as "spam.tgz" therefore recorded "spam.tgz", so decompressors that honor FNAME wrote out a tar file still named ".tgz". Match gunzip instead: compare the suffix ignoring case, and replace ".tgz" and ".taz" with ".tar" rather than leaving them in place. This covers both code paths, GzipFile._write_gzip_header (mode "w:gz") and tarfile._Stream._init_write_gz (mode "w|gz"). Only the suffix is matched ignoring case; the rest of the name keeps the case it was given. gunzip behaves the same way: get_suffix() lowercases only the trailing bytes it compares, and make_ofname() calls strlwr() on the suffix alone, so "SPAM.TGZ" becomes "SPAM.tar". Both branches are tested with an upper-case stem. Deliberately left out, being separate user-visible changes rather than part of deriving the FNAME field: the remaining suffixes gunzip knows (".z", "-gz", "-z" and "_z"), and the "python -m gzip -d" CLI, which still accepts only a literal ".gz" argument. ".tz" is not added because it is not a gunzip suffix at all, unlike ".taz". --- Doc/whatsnew/3.16.rst | 10 +++++ Lib/gzip.py | 6 ++- Lib/tarfile.py | 6 ++- Lib/test/test_gzip.py | 34 +++++++++++++++++ Lib/test/test_tarfile.py | 38 ++++++++++++++++++- ...6-09-24-18-23-43.gh-issue-88661.vOpGJy.rst | 6 +++ 6 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-24-18-23-43.gh-issue-88661.vOpGJy.rst diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 3d3125ad17b126b..bd3aa3253b45fc3 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -1045,6 +1045,16 @@ that may require changes to your code. raises :exc:`io.UnsupportedOperation` unless buffering is disabled. (Contributed by An Long in :gh:`86768`.) +* :mod:`gzip` and :mod:`tarfile` now derive the ``FNAME`` field of the gzip + header from the file name like :program:`gunzip` does for the ``.gz``, + ``.tgz`` and ``.taz`` suffixes: the suffix is matched ignoring case, and + ``.tgz`` and ``.taz`` are replaced with ``.tar`` instead of being left in + place. For example, an archive created as :file:`spam.tgz` now records + ``spam.tar`` rather than ``spam.tgz``, and :file:`spam.GZ` records + ``spam`` rather than ``spam.GZ``. Code comparing generated files byte for + byte may need to be updated. + (Contributed by Dmitry Voropaev in :gh:`88661`.) + Build changes ============= diff --git a/Lib/gzip.py b/Lib/gzip.py index 247efc01527bb14..5a1d018e42da4b9 100644 --- a/Lib/gzip.py +++ b/Lib/gzip.py @@ -288,8 +288,12 @@ def _write_gzip_header(self, compresslevel): fname = os.path.basename(self.name) if not isinstance(fname, bytes): fname = fname.encode('latin-1') - if fname.endswith(b'.gz'): + # Like gunzip, match the suffix ignoring case, and turn ".tgz" + # and ".taz" into ".tar" instead of just stripping them. + if fname[-3:].lower() == b'.gz': fname = fname[:-3] + elif fname[-4:].lower() in (b'.tgz', b'.taz'): + fname = fname[:-4] + b'.tar' except UnicodeEncodeError: fname = b'' flags = 0 diff --git a/Lib/tarfile.py b/Lib/tarfile.py index a4f9ce3311f6dad..d5fc75bc523b3f6 100644 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -456,8 +456,12 @@ def _init_write_gz(self, compresslevel, mtime): mtime = int(time.time()) timestamp = struct.pack(" Date: Fri, 25 Sep 2026 12:42:54 +0300 Subject: [PATCH 2/3] gh-88661: Drop .taz, and teach the command line the same suffixes .taz names a .tar.Z, and this module does not do compress(1), so mapping it to .tar was wrong. Only .gz and .tgz are handled now. The suffix logic moved into a small helper that both the header writer and main() use, so python -m gzip -d accepts what gunzip accepts and writes the name gunzip would write. It rejected spam.tgz and spam.GZ before, which was the inconsistency Serhiy pointed out. The existing command-line error test asserted the old message text; it now asserts the new one and also covers .taz, which is refused. --- Doc/whatsnew/3.16.rst | 14 +++---- Lib/gzip.py | 35 ++++++++++++---- Lib/tarfile.py | 7 ++-- Lib/test/test_gzip.py | 42 +++++++++++++++---- Lib/test/test_tarfile.py | 9 ++-- ...6-09-24-18-23-43.gh-issue-88661.vOpGJy.rst | 8 ++-- 6 files changed, 79 insertions(+), 36 deletions(-) diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index bd3aa3253b45fc3..3d514b7bd316404 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -1046,13 +1046,13 @@ that may require changes to your code. (Contributed by An Long in :gh:`86768`.) * :mod:`gzip` and :mod:`tarfile` now derive the ``FNAME`` field of the gzip - header from the file name like :program:`gunzip` does for the ``.gz``, - ``.tgz`` and ``.taz`` suffixes: the suffix is matched ignoring case, and - ``.tgz`` and ``.taz`` are replaced with ``.tar`` instead of being left in - place. For example, an archive created as :file:`spam.tgz` now records - ``spam.tar`` rather than ``spam.tgz``, and :file:`spam.GZ` records - ``spam`` rather than ``spam.GZ``. Code comparing generated files byte for - byte may need to be updated. + header from the file name like :program:`gunzip` does: the suffix is matched + ignoring case, and ``.tgz`` is replaced with ``.tar`` instead of being left + in place. For example, an archive created as :file:`spam.tgz` now records + ``spam.tar`` rather than ``spam.tgz``, and :file:`spam.GZ` records ``spam`` + rather than ``spam.GZ``. Code comparing generated files byte for byte may + need to be updated. ``python -m gzip -d`` accepts the same names, where it + previously refused anything not ending in a lowercase ``.gz``. (Contributed by Dmitry Voropaev in :gh:`88661`.) diff --git a/Lib/gzip.py b/Lib/gzip.py index 5a1d018e42da4b9..4853ace32167aa7 100644 --- a/Lib/gzip.py +++ b/Lib/gzip.py @@ -145,6 +145,25 @@ def writable(self): return True +def _gunzip_name(name): + """The name :program:`gunzip` would decompress *name* to, or ``None``. + + The suffix is matched ignoring case, and ``.tgz`` becomes ``.tar`` rather + than being stripped. ``.taz`` is not handled: it means a ``.tar.Z``, and + this module does not do :program:`compress`. Accepts and returns either + :class:`str` or :class:`bytes`. + """ + if isinstance(name, bytes): + gz, tgz, tar = b'.gz', b'.tgz', b'.tar' + else: + gz, tgz, tar = '.gz', '.tgz', '.tar' + if name[-3:].lower() == gz: + return name[:-3] + if name[-4:].lower() == tgz: + return name[:-4] + tar + return None + + class GzipFile(_streams.BaseStream): """The GzipFile class simulates most of the methods of a file object with the exception of the truncate() method. @@ -288,12 +307,9 @@ def _write_gzip_header(self, compresslevel): fname = os.path.basename(self.name) if not isinstance(fname, bytes): fname = fname.encode('latin-1') - # Like gunzip, match the suffix ignoring case, and turn ".tgz" - # and ".taz" into ".tar" instead of just stripping them. - if fname[-3:].lower() == b'.gz': - fname = fname[:-3] - elif fname[-4:].lower() in (b'.tgz', b'.taz'): - fname = fname[:-4] + b'.tar' + stripped = _gunzip_name(fname) + if stripped is not None: + fname = stripped except UnicodeEncodeError: fname = b'' flags = 0 @@ -733,10 +749,11 @@ def main(): f = GzipFile(filename="", mode="rb", fileobj=sys.stdin.buffer) g = sys.stdout.buffer else: - if arg[-3:] != ".gz": - sys.exit(f"filename doesn't end in .gz: {arg!r}") + out = _gunzip_name(arg) + if out is None: + sys.exit(f"filename doesn't end in .gz or .tgz: {arg!r}") f = open(arg, "rb") - g = builtins.open(arg[:-3], "wb") + g = builtins.open(out, "wb") else: if arg == "-": f = sys.stdin.buffer diff --git a/Lib/tarfile.py b/Lib/tarfile.py index d5fc75bc523b3f6..f908d23cc6d9ab9 100644 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -456,11 +456,12 @@ def _init_write_gz(self, compresslevel, mtime): mtime = int(time.time()) timestamp = struct.pack(" Date: Fri, 25 Sep 2026 13:14:29 +0300 Subject: [PATCH 3/3] gh-88661: Give the command-line test cases distinct stems The three names differed only in case, so on a case-insensitive filesystem the second write landed on the first file. The iOS simulator is one, and the job failed there with FileExistsError. --- Lib/test/test_gzip.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_gzip.py b/Lib/test/test_gzip.py index f33aa69b0857815..1c4b06f388de8bc 100644 --- a/Lib/test/test_gzip.py +++ b/Lib/test/test_gzip.py @@ -1201,9 +1201,11 @@ def test_decompress_infile_outfile_error(self): def test_decompress_suffix_like_gunzip(self): # gh-88661: the command line accepts the names gunzip accepts, and # writes the name gunzip would write. - for name, expected in (('testgzip.tgz', 'testgzip.tar'), - ('testgzip.TGZ', 'testgzip.tar'), - ('testgzip.GZ', 'testgzip')): + # Distinct stems: the names differ only in case on some platforms, + # and a case-insensitive filesystem would have them collide. + for name, expected in (('lower.tgz', 'lower.tar'), + ('upper.TGZ', 'upper.tar'), + ('caps.GZ', 'caps')): with self.subTest(name=name): path = os.path.join(TEMPDIR, name) with gzip.open(path, mode='wb') as fp: