Skip to content

Commit 27521dd

Browse files
committed
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.
1 parent 9dcef46 commit 27521dd

6 files changed

Lines changed: 79 additions & 36 deletions

File tree

‎Doc/whatsnew/3.16.rst‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,13 +1046,13 @@ that may require changes to your code.
10461046
(Contributed by An Long in :gh:`86768`.)
10471047

10481048
* :mod:`gzip` and :mod:`tarfile` now derive the ``FNAME`` field of the gzip
1049-
header from the file name like :program:`gunzip` does for the ``.gz``,
1050-
``.tgz`` and ``.taz`` suffixes: the suffix is matched ignoring case, and
1051-
``.tgz`` and ``.taz`` are replaced with ``.tar`` instead of being left in
1052-
place. For example, an archive created as :file:`spam.tgz` now records
1053-
``spam.tar`` rather than ``spam.tgz``, and :file:`spam.GZ` records
1054-
``spam`` rather than ``spam.GZ``. Code comparing generated files byte for
1055-
byte may need to be updated.
1049+
header from the file name like :program:`gunzip` does: the suffix is matched
1050+
ignoring case, and ``.tgz`` is replaced with ``.tar`` instead of being left
1051+
in place. For example, an archive created as :file:`spam.tgz` now records
1052+
``spam.tar`` rather than ``spam.tgz``, and :file:`spam.GZ` records ``spam``
1053+
rather than ``spam.GZ``. Code comparing generated files byte for byte may
1054+
need to be updated. ``python -m gzip -d`` accepts the same names, where it
1055+
previously refused anything not ending in a lowercase ``.gz``.
10561056
(Contributed by Dmitry Voropaev in :gh:`88661`.)
10571057

10581058

‎Lib/gzip.py‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,25 @@ def writable(self):
145145
return True
146146

147147

148+
def _gunzip_name(name):
149+
"""The name :program:`gunzip` would decompress *name* to, or ``None``.
150+
151+
The suffix is matched ignoring case, and ``.tgz`` becomes ``.tar`` rather
152+
than being stripped. ``.taz`` is not handled: it means a ``.tar.Z``, and
153+
this module does not do :program:`compress`. Accepts and returns either
154+
:class:`str` or :class:`bytes`.
155+
"""
156+
if isinstance(name, bytes):
157+
gz, tgz, tar = b'.gz', b'.tgz', b'.tar'
158+
else:
159+
gz, tgz, tar = '.gz', '.tgz', '.tar'
160+
if name[-3:].lower() == gz:
161+
return name[:-3]
162+
if name[-4:].lower() == tgz:
163+
return name[:-4] + tar
164+
return None
165+
166+
148167
class GzipFile(_streams.BaseStream):
149168
"""The GzipFile class simulates most of the methods of a file object with
150169
the exception of the truncate() method.
@@ -288,12 +307,9 @@ def _write_gzip_header(self, compresslevel):
288307
fname = os.path.basename(self.name)
289308
if not isinstance(fname, bytes):
290309
fname = fname.encode('latin-1')
291-
# Like gunzip, match the suffix ignoring case, and turn ".tgz"
292-
# and ".taz" into ".tar" instead of just stripping them.
293-
if fname[-3:].lower() == b'.gz':
294-
fname = fname[:-3]
295-
elif fname[-4:].lower() in (b'.tgz', b'.taz'):
296-
fname = fname[:-4] + b'.tar'
310+
stripped = _gunzip_name(fname)
311+
if stripped is not None:
312+
fname = stripped
297313
except UnicodeEncodeError:
298314
fname = b''
299315
flags = 0
@@ -733,10 +749,11 @@ def main():
733749
f = GzipFile(filename="", mode="rb", fileobj=sys.stdin.buffer)
734750
g = sys.stdout.buffer
735751
else:
736-
if arg[-3:] != ".gz":
737-
sys.exit(f"filename doesn't end in .gz: {arg!r}")
752+
out = _gunzip_name(arg)
753+
if out is None:
754+
sys.exit(f"filename doesn't end in .gz or .tgz: {arg!r}")
738755
f = open(arg, "rb")
739-
g = builtins.open(arg[:-3], "wb")
756+
g = builtins.open(out, "wb")
740757
else:
741758
if arg == "-":
742759
f = sys.stdin.buffer

‎Lib/tarfile.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -456,11 +456,12 @@ def _init_write_gz(self, compresslevel, mtime):
456456
mtime = int(time.time())
457457
timestamp = struct.pack("<L", mtime)
458458
self.__write(b"\037\213\010\010" + timestamp + b"\002\377")
459-
# Like gunzip, match the suffix ignoring case, and turn ".tgz"
460-
# and ".taz" into ".tar" instead of just stripping them.
459+
# Like gunzip, match the suffix ignoring case, and turn ".tgz" into
460+
# ".tar" instead of just stripping it. (gzip._gunzip_name does the
461+
# same, but importing gzip here would pull it into the w|gz path.)
461462
if self.name[-3:].lower() == ".gz":
462463
self.name = self.name[:-3]
463-
elif self.name[-4:].lower() in (".tgz", ".taz"):
464+
elif self.name[-4:].lower() == ".tgz":
464465
self.name = self.name[:-4] + ".tar"
465466
# Honor "directory components removed" from RFC1952
466467
self.name = os.path.basename(self.name)

‎Lib/test/test_gzip.py‎

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,7 @@ def test_metadata_ascii_name(self):
440440
def test_metadata_name_suffix(self):
441441
# gh-88661: the FNAME field holds the name that the file is expected
442442
# to have after decompression. Like gunzip, the suffix is matched
443-
# ignoring case, and ".tgz" and ".taz" are turned into ".tar".
443+
# ignoring case, and ".tgz" is turned into ".tar".
444444
base = os_helper.TESTFN_ASCII
445445
# Only the suffix is matched ignoring case; the rest of the name
446446
# keeps the case it was given, the way make_ofname() does in gunzip.
@@ -452,13 +452,12 @@ def test_metadata_name_suffix(self):
452452
(base + '.tgz', base + '.tar'),
453453
(base + '.TGZ', base + '.tar'),
454454
(base + '.tGz', base + '.tar'),
455-
(base + '.taz', base + '.tar'),
456-
(base + '.TAZ', base + '.tar'),
457455
(base + '.tar', base + '.tar'),
456+
# .taz means .tar.Z, which this module cannot read.
457+
(base + '.taz', base + '.taz'),
458458
(base + '.tgz.gz', base + '.tgz'),
459459
(upper + '.GZ', upper),
460-
(upper + '.TGZ', upper + '.tar'),
461-
(upper + '.TAZ', upper + '.tar')):
460+
(upper + '.TGZ', upper + '.tar')):
462461
with self.subTest(filename=filename):
463462
try:
464463
with gzip.GzipFile(filename, 'w') as f:
@@ -1187,10 +1186,35 @@ def test_decompress_infile_outfile(self):
11871186
self.assertEqual(err, b'')
11881187

11891188
def test_decompress_infile_outfile_error(self):
1190-
rc, out, err = assert_python_failure('-m', 'gzip', '-d', 'thisisatest.out')
1191-
self.assertEqual(b"filename doesn't end in .gz: 'thisisatest.out'", err.strip())
1192-
self.assertEqual(rc, 1)
1193-
self.assertEqual(out, b'')
1189+
# ".taz" is a ".tar.Z", which this module cannot read, so it is
1190+
# rejected along with names that carry no gzip suffix at all.
1191+
for name in ('thisisatest.out', 'thisisatest.taz'):
1192+
with self.subTest(name=name):
1193+
rc, out, err = assert_python_failure('-m', 'gzip', '-d', name)
1194+
self.assertEqual(
1195+
f"filename doesn't end in .gz or .tgz: {name!r}".encode(),
1196+
err.strip())
1197+
self.assertEqual(rc, 1)
1198+
self.assertEqual(out, b'')
1199+
1200+
@create_and_remove_directory(TEMPDIR)
1201+
def test_decompress_suffix_like_gunzip(self):
1202+
# gh-88661: the command line accepts the names gunzip accepts, and
1203+
# writes the name gunzip would write.
1204+
for name, expected in (('testgzip.tgz', 'testgzip.tar'),
1205+
('testgzip.TGZ', 'testgzip.tar'),
1206+
('testgzip.GZ', 'testgzip')):
1207+
with self.subTest(name=name):
1208+
path = os.path.join(TEMPDIR, name)
1209+
with gzip.open(path, mode='wb') as fp:
1210+
fp.write(self.data)
1211+
1212+
rc, out, err = assert_python_ok('-m', 'gzip', '-d', path)
1213+
self.assertEqual(rc, 0)
1214+
self.assertEqual(err, b'')
1215+
1216+
with open(os.path.join(TEMPDIR, expected), 'rb') as gunziped:
1217+
self.assertEqual(gunziped.read(), self.data)
11941218

11951219
@requires_subprocess()
11961220
@create_and_remove_directory(TEMPDIR)

‎Lib/test/test_tarfile.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1970,7 +1970,7 @@ def test_missing_fileobj(self):
19701970
class GzipFnameTestBase:
19711971
# gh-88661: the FNAME field of the gzip header holds the name that the
19721972
# archive is expected to have after decompression. Like gunzip, the
1973-
# suffix is matched ignoring case, and ".tgz" and ".taz" become ".tar".
1973+
# suffix is matched ignoring case, and ".tgz" becomes ".tar".
19741974

19751975
def gzip_header_fname(self, path):
19761976
with open(path, "rb") as fobj:
@@ -1987,10 +1987,11 @@ def test_fname(self):
19871987
("tmp.tgz", "tmp.tar"),
19881988
("tmp.TGZ", "tmp.tar"),
19891989
("tmp.tGz", "tmp.tar"),
1990-
("tmp.taz", "tmp.tar"),
1991-
("tmp.TAZ", "tmp.tar"),
1990+
# .taz means .tar.Z, not gzip.
1991+
("tmp.taz", "tmp.taz"),
1992+
("tmp.TAZ", "tmp.TAZ"),
19921993
("TMP.TGZ", "TMP.tar"),
1993-
("TMP.TAZ", "TMP.tar"),
1994+
("TMP.TAZ", "TMP.TAZ"),
19941995
("tmp.tar", "tmp.tar")):
19951996
with self.subTest(name=name):
19961997
path = os.path.join(TEMPDIR, name)
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
When writing a gzip header, :mod:`gzip` and :mod:`tarfile` now derive the
2-
``FNAME`` field from the file name like :program:`gunzip` does for the
3-
``.gz``, ``.tgz`` and ``.taz`` suffixes: the suffix is matched ignoring
4-
case, and ``.tgz`` and ``.taz`` are replaced with ``.tar`` instead of being
2+
``FNAME`` field from the file name like :program:`gunzip` does: the suffix is
3+
matched ignoring case, and ``.tgz`` is replaced with ``.tar`` instead of being
54
left in place. Previously an archive created as :file:`spam.tgz` recorded
6-
``spam.tgz`` as the name to decompress to.
5+
``spam.tgz`` as the name to decompress to. ``python -m gzip -d`` now accepts
6+
the same names, so :file:`spam.tgz` and :file:`spam.GZ` are no longer refused.

0 commit comments

Comments
 (0)