From 4a5c4ec1b1c67bd0850dd72383ceed7d5d881ba2 Mon Sep 17 00:00:00 2001 From: Shashank Tomar Date: Sun, 8 Nov 2026 13:29:15 +0530 Subject: [PATCH] Fix deprecated UTC and tar extraction calls #75 * Use a timezone-aware datetime in time2tstamp() and drop the timezone to keep returning a naive UTC datetime as before. * Pass an explicit filter to TarFile.extractall() when supported. This silences the Python 3.12+ DeprecationWarning and keeps the historical extraction behavior with the "fully_trusted" filter on Python 3.14+. * Add tests for the timestamps and for tar extraction with and without an explicit filter. Signed-off-by: Shashank Tomar --- src/commoncode/archive.py | 26 +++++++-- src/commoncode/timeutils.py | 5 +- tests/test_archive.py | 109 ++++++++++++++++++++++++++++++++++++ tests/test_timeutils.py | 16 +++++- 4 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 tests/test_archive.py diff --git a/src/commoncode/archive.py b/src/commoncode/archive.py index 1d3e42cc..7e9b03c7 100644 --- a/src/commoncode/archive.py +++ b/src/commoncode/archive.py @@ -14,13 +14,30 @@ from os import path from commoncode.system import on_windows -from commoncode.system import py314 """ Mimimal tar and zip file handling, primarily for testing. """ +def _extractall(tar, target_dir, members=None, filter=None): + """ + Extract `members` from `tar` in `target_dir`. + + Always pass an explicit ``filter`` to ``tarfile.extractall()`` when this + is supported. Python 3.12 deprecated extracting without a filter and + Python 3.14 changes the default to ``data``, which would silently drop + permissions, owners, links and other metadata. Using ``fully_trusted`` + keeps the historical behavior unless a caller requests another filter. + """ + filter = filter or "fully_trusted" + try: + tar.extractall(target_dir, members=members, filter=filter) + except TypeError: + # Python versions that do not support the ``filter`` argument + tar.extractall(target_dir, members=members) + + def _extract_tar_raw(test_path, target_dir, to_bytes, *args, **kwargs): """ Raw simplified extract for certain really weird paths and file @@ -29,7 +46,7 @@ def _extract_tar_raw(test_path, target_dir, to_bytes, *args, **kwargs): tar = None try: tar = tarfile.open(test_path) - tar.extractall(path=target_dir) + _extractall(tar, target_dir) finally: if tar: tar.close() @@ -59,10 +76,7 @@ def extract_tar(location, target_dir, verbatim=False, filter=None, *args, **kwar if not verbatim: tarinfo.mode = 0o755 to_extract.append(tarinfo) - if py314 and filter: - tar.extractall(target_dir, members=to_extract, filter=filter) - else: - tar.extractall(target_dir, members=to_extract) + _extractall(tar, target_dir, members=to_extract, filter=filter) finally: if tar: tar.close() diff --git a/src/commoncode/timeutils.py b/src/commoncode/timeutils.py index 45caf595..5609bdb5 100644 --- a/src/commoncode/timeutils.py +++ b/src/commoncode/timeutils.py @@ -7,6 +7,7 @@ # from datetime import datetime +from datetime import timezone from datetime import tzinfo from functools import update_wrapper from functools import wraps @@ -58,7 +59,9 @@ def time2tstamp(dt=None, path_safe=True): to convert back to a datetime object. """ # TODO: check that the dt is effectively in UTC - datim = dt or datetime.utcnow() + # datetime.utcnow() is deprecated in Python 3.12+. Use a timezone-aware + # datetime and drop the timezone to keep returning a naive UTC datetime. + datim = dt or datetime.now(timezone.utc).replace(tzinfo=None) iso = datim.isoformat() if path_safe: iso = iso.replace(":", "").replace("/", "_") diff --git a/tests/test_archive.py b/tests/test_archive.py new file mode 100644 index 00000000..95d8aa04 --- /dev/null +++ b/tests/test_archive.py @@ -0,0 +1,109 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/commoncode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +import io +import tarfile +import warnings + +import pytest + +from commoncode.archive import extract_tar +from commoncode.archive import extract_tar_raw + + +def make_tar(location, name="file.txt", content=b"hello"): + with tarfile.open(location, "w") as tar: + info = tarfile.TarInfo(name) + info.size = len(content) + tar.addfile(info, io.BytesIO(content)) + + +def has_tar_deprecation_warning(caught): + return [ + warning + for warning in caught + if issubclass(warning.category, DeprecationWarning) + and "tar archive" in str(warning.message).lower() + ] + + +def test_extract_tar_does_not_emit_a_deprecation_warning(tmp_path): + archive = str(tmp_path / "test.tar") + make_tar(archive) + target_dir = str(tmp_path / "target") + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + extract_tar(archive, target_dir) + + assert not has_tar_deprecation_warning(caught) + with open(str(tmp_path / "target" / "file.txt")) as extracted: + assert extracted.read() == "hello" + + +def test_extract_tar_raw_does_not_emit_a_deprecation_warning(tmp_path): + archive = str(tmp_path / "test.tar") + make_tar(archive) + target_dir = str(tmp_path / "target") + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + extract_tar_raw(archive, target_dir) + + assert not has_tar_deprecation_warning(caught) + with open(str(tmp_path / "target" / "file.txt")) as extracted: + assert extracted.read() == "hello" + + +def test_extract_tar_honors_an_explicit_filter(tmp_path): + if not hasattr(tarfile, "FilterError"): + pytest.skip("tarfile extraction filters require Python 3.12+") + + archive = str(tmp_path / "test.tar") + make_tar(archive, name="../escaped.txt") + target_dir = tmp_path / "target" + target_dir.mkdir() + + # The "data" filter rejects members that would escape the target directory + try: + extract_tar(str(archive), str(target_dir), filter="data") + except tarfile.FilterError: + pass + else: + raise AssertionError("expected the 'data' filter to reject ../escaped.txt") + + assert not (tmp_path / "escaped.txt").exists() + + +class LegacyTar: + """ + Simulate a tarfile.TarFile from a Python without tar extraction filters. + """ + + def __init__(self): + self.calls = [] + + def extractall(self, path, members=None, **kwargs): + if "filter" in kwargs: + raise TypeError("TarFile.extractall() got an unexpected keyword argument 'filter'") + self.calls.append((path, members)) + + +def test_extractall_falls_back_on_pythons_without_filters(tmp_path): + from commoncode.archive import _extractall + + tar = LegacyTar() + target_dir = str(tmp_path) + + _extractall(tar, target_dir, members=["member"], filter="data") + _extractall(tar, target_dir, members=["member"]) + + assert tar.calls == [ + (target_dir, ["member"]), + (target_dir, ["member"]), + ] diff --git a/tests/test_timeutils.py b/tests/test_timeutils.py index 568b6425..e582679f 100644 --- a/tests/test_timeutils.py +++ b/tests/test_timeutils.py @@ -6,7 +6,9 @@ # See https://aboutcode.org for more information about nexB OSS projects. # +import warnings from datetime import datetime +from datetime import timezone from commoncode.testcase import FileBasedTesting from commoncode.timeutils import UTC @@ -34,11 +36,23 @@ def test_time2tstamp_raises_on_non_datetime(self): self.assertRaises(AttributeError, time2tstamp, 1) def test_time2tstamp_tstamp2time_is_idempotent(self): - dt = datetime.utcnow() + dt = datetime.now(timezone.utc).replace(tzinfo=None) ts = time2tstamp(dt) dt_from_ts = tstamp2time(ts) assert dt_from_ts == dt + def test_time2tstamp_does_not_emit_deprecation_warnings(self): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + time2tstamp() + + utcnow_warnings = [ + str(warning.message) + for warning in caught + if issubclass(warning.category, DeprecationWarning) and "utcnow" in str(warning.message) + ] + assert not utcnow_warnings + def test_tstamp2time_format(self): import re