Update Python version support to 3.12+

This commit updates the CI configuration, pyproject.toml, and codebase to drop support for Python versions below 3.12 and add support for Python 3.13. Type hints are updated to use modern syntax, and redundant checks for extraction filter support are removed as Python 3.12+ always supports filters.
This commit is contained in:
xixu-me committed 2025-05-31 15:48:13 +08:00
1 parent 5de5036bba
commit 6b57409712
5 files changed
+107 -153

No files matched your search

+4 -4
View File
@@ -14,7 +14,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
python-version: ["3.12", "3.13"]
steps:
- uses: actions/checkout@v4
@@ -42,7 +42,7 @@ jobs:
pytest -v --cov=tzst --cov-report=xml
- name: Upload coverage to Codecov
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11'
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
@@ -58,7 +58,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.11"
python-version: "3.12"
- name: Install build dependencies
run: |
@@ -116,7 +116,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.11"
python-version: "3.12"
- name: Fetch PyPI version
id: pypi_version
+4 -7
View File
@@ -8,7 +8,7 @@ dynamic = ["version"]
description = "A Python library for creating and manipulating .tzst/.tar.zst archives"
readme = "README.md"
license = "BSD-3-Clause"
requires-python = ">=3.8"
requires-python = ">=3.12"
authors = [{ name = "tzst contributors" }]
keywords = ["tar", "zstandard", "compression", "archive"]
classifiers = [
@@ -17,11 +17,8 @@ classifiers = [
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: System :: Archiving :: Compression",
"Topic :: Software Development :: Libraries :: Python Modules",
]
@@ -56,7 +53,7 @@ python_functions = ["test_*"]
addopts = "--cov=tzst --cov-report=term-missing --cov-report=html"
[tool.ruff]
target-version = "py38"
target-version = "py312"
line-length = 88
[tool.ruff.lint]
@@ -77,5 +74,5 @@ ignore = [
"tests/**/*" = ["E501"]
[tool.black]
target-version = ["py38", "py39", "py310", "py311", "py312"]
target-version = ["py312", "py313"]
line-length = 88
+2 -3
View File
@@ -3,7 +3,6 @@
import argparse
import sys
from pathlib import Path
from typing import List, Optional
from . import __version__
from .core import create_archive, extract_archive, list_archive, test_archive
@@ -30,7 +29,7 @@ def cmd_add(args) -> int:
"""Add/create archive command with atomic file operations."""
try:
archive_path = Path(args.archive)
files: List[Path] = [Path(f) for f in args.files]
files: list[Path] = [Path(f) for f in args.files]
# Check if files exist
missing_files = [f for f in files if not f.exists()]
@@ -407,7 +406,7 @@ Documentation:
return parser
def main(argv: Optional[List[str]] = None) -> int:
def main(argv: list[str] | None = None) -> int:
"""Main entry point for the CLI."""
print_banner()
print()
+32 -41
View File
@@ -5,23 +5,21 @@ import os
import tarfile
import tempfile
import time
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import BinaryIO, Callable, List, Optional, Sequence, Union
from typing import BinaryIO
import zstandard as zstd
from .exceptions import TzstArchiveError, TzstDecompressionError
# Check if extraction filters are supported (Python 3.12+)
EXTRACTION_FILTERS_SUPPORTED = hasattr(tarfile, "data_filter")
class TzstArchive:
"""A class for handling .tzst/.tar.zst archives."""
def __init__(
self,
filename: Union[str, Path],
filename: str | Path,
mode: str = "r",
compression_level: int = 3,
streaming: bool = False,
@@ -41,11 +39,14 @@ class TzstArchive:
self.mode = mode
self.compression_level = compression_level
self.streaming = streaming
self._tarfile: Optional[tarfile.TarFile] = None
self._fileobj: Optional[BinaryIO] = None
self._compressed_stream: Optional[
Union[zstd.ZstdCompressionWriter, zstd.ZstdDecompressionReader, io.BytesIO]
] = None
self._tarfile: tarfile.TarFile | None = None
self._fileobj: BinaryIO | None = None
self._compressed_stream: (
zstd.ZstdCompressionWriter
| zstd.ZstdDecompressionReader
| io.BytesIO
| None
) = None
# Validate mode
valid_modes = ["r", "w", "a"]
@@ -162,8 +163,8 @@ class TzstArchive:
def add(
self,
name: Union[str, Path],
arcname: Optional[str] = None,
name: str | Path,
arcname: str | None = None,
recursive: bool = True,
):
"""
@@ -187,11 +188,11 @@ class TzstArchive:
def extract(
self,
member: Optional[str] = None,
path: Union[str, Path] = ".",
member: str | None = None,
path: str | Path = ".",
set_attrs: bool = True,
numeric_owner: bool = False,
filter: Optional[Union[str, Callable]] = "data",
filter: str | Callable | None = "data",
):
"""
Extract files from the archive.
@@ -233,18 +234,8 @@ class TzstArchive:
"Please use non-streaming mode for selective extraction, or extract all files."
)
# Prepare extraction arguments
extract_kwargs = {}
# Add filter argument if supported (Python 3.12+)
if EXTRACTION_FILTERS_SUPPORTED:
extract_kwargs["filter"] = filter
elif filter is not None and filter != "data":
# Warn if user specified a filter but it's not supported
print(
"Warning: Extraction filters are not supported in this Python version. "
"Consider upgrading to Python 3.12+ for enhanced security features.",
)
# Prepare extraction arguments - filters are always supported in Python 3.12+
extract_kwargs = {"filter": filter}
try:
if member:
@@ -263,7 +254,7 @@ class TzstArchive:
else:
raise
def extractfile(self, member: Union[str, tarfile.TarInfo]):
def extractfile(self, member: str | tarfile.TarInfo):
"""
Extract a file-like object from the archive.
@@ -280,7 +271,7 @@ class TzstArchive:
return self._tarfile.extractfile(member)
def getmembers(self) -> List[tarfile.TarInfo]:
def getmembers(self) -> list[tarfile.TarInfo]:
"""Get list of all members in the archive."""
if not self._tarfile:
raise RuntimeError("Archive not open")
@@ -289,7 +280,7 @@ class TzstArchive:
return self._tarfile.getmembers()
def getnames(self) -> List[str]:
def getnames(self) -> list[str]:
"""Get list of all member names in the archive."""
if not self._tarfile:
raise RuntimeError("Archive not open")
@@ -298,7 +289,7 @@ class TzstArchive:
return self._tarfile.getnames()
def list(self, verbose: bool = False) -> List[dict]:
def list(self, verbose: bool = False) -> list[dict]:
"""
List contents of the archive.
@@ -379,8 +370,8 @@ class TzstArchive:
def create_archive(
archive_path: Union[str, Path],
files: Sequence[Union[str, Path]],
archive_path: str | Path,
files: Sequence[str | Path],
compression_level: int = 3,
use_temp_file: bool = True,
) -> None:
@@ -442,7 +433,7 @@ def create_archive(
def _create_archive_impl(
archive_path: Path,
files: Sequence[Union[str, Path]],
files: Sequence[str | Path],
compression_level: int,
) -> None:
"""Internal implementation for creating archives."""
@@ -477,12 +468,12 @@ def _create_archive_impl(
def extract_archive(
archive_path: Union[str, Path],
extract_path: Union[str, Path] = ".",
members: Optional[List[str]] = None,
archive_path: str | Path,
extract_path: str | Path = ".",
members: list[str] | None = None,
flatten: bool = False,
streaming: bool = False,
filter: Optional[Union[str, Callable]] = "data",
filter: str | Callable | None = "data",
) -> None:
"""
Extract files from a .tzst archive.
@@ -534,8 +525,8 @@ def extract_archive(
def list_archive(
archive_path: Union[str, Path], verbose: bool = False, streaming: bool = False
) -> List[dict]:
archive_path: str | Path, verbose: bool = False, streaming: bool = False
) -> list[dict]:
"""
List contents of a .tzst archive.
@@ -551,7 +542,7 @@ def list_archive(
return archive.list(verbose=verbose)
def test_archive(archive_path: Union[str, Path], streaming: bool = False) -> bool:
def test_archive(archive_path: str | Path, streaming: bool = False) -> bool:
"""
Test the integrity of a .tzst archive.
+65 -98
View File
@@ -3,7 +3,6 @@
from unittest.mock import patch
from tzst import TzstArchive, extract_archive
from tzst.core import EXTRACTION_FILTERS_SUPPORTED
class TestExtractionFilters:
@@ -23,16 +22,15 @@ class TestExtractionFilters:
# Test that default filter is 'data'
extract_dir = temp_dir / "extracted_default"
if EXTRACTION_FILTERS_SUPPORTED:
with patch("tarfile.TarFile.extractall") as mock_extractall:
with TzstArchive(archive_path, "r") as archive:
archive.extract(path=extract_dir)
with patch("tarfile.TarFile.extractall") as mock_extractall:
with TzstArchive(archive_path, "r") as archive:
archive.extract(path=extract_dir)
# Verify that 'data' filter was used
mock_extractall.assert_called_once()
call_args = mock_extractall.call_args
assert "filter" in call_args[1]
assert call_args[1]["filter"] == "data"
# Verify that 'data' filter was used
mock_extractall.assert_called_once()
call_args = mock_extractall.call_args
assert "filter" in call_args[1]
assert call_args[1]["filter"] == "data"
def test_data_filter_explicit(self, sample_files, temp_dir):
"""Test explicitly setting 'data' filter."""
@@ -48,14 +46,13 @@ class TestExtractionFilters:
# Test extraction with explicit 'data' filter
extract_dir = temp_dir / "extracted_data"
if EXTRACTION_FILTERS_SUPPORTED:
with patch("tarfile.TarFile.extractall") as mock_extractall:
with TzstArchive(archive_path, "r") as archive:
archive.extract(path=extract_dir, filter="data")
with patch("tarfile.TarFile.extractall") as mock_extractall:
with TzstArchive(archive_path, "r") as archive:
archive.extract(path=extract_dir, filter="data")
mock_extractall.assert_called_once()
call_args = mock_extractall.call_args
assert call_args[1]["filter"] == "data"
mock_extractall.assert_called_once()
call_args = mock_extractall.call_args
assert call_args[1]["filter"] == "data"
def test_tar_filter(self, sample_files, temp_dir):
"""Test 'tar' filter for Unix-like features."""
@@ -71,14 +68,13 @@ class TestExtractionFilters:
# Test extraction with 'tar' filter
extract_dir = temp_dir / "extracted_tar"
if EXTRACTION_FILTERS_SUPPORTED:
with patch("tarfile.TarFile.extractall") as mock_extractall:
with TzstArchive(archive_path, "r") as archive:
archive.extract(path=extract_dir, filter="tar")
with patch("tarfile.TarFile.extractall") as mock_extractall:
with TzstArchive(archive_path, "r") as archive:
archive.extract(path=extract_dir, filter="tar")
mock_extractall.assert_called_once()
call_args = mock_extractall.call_args
assert call_args[1]["filter"] == "tar"
mock_extractall.assert_called_once()
call_args = mock_extractall.call_args
assert call_args[1]["filter"] == "tar"
def test_fully_trusted_filter(self, sample_files, temp_dir):
"""Test 'fully_trusted' filter (dangerous but complete)."""
@@ -94,14 +90,13 @@ class TestExtractionFilters:
# Test extraction with 'fully_trusted' filter
extract_dir = temp_dir / "extracted_trusted"
if EXTRACTION_FILTERS_SUPPORTED:
with patch("tarfile.TarFile.extractall") as mock_extractall:
with TzstArchive(archive_path, "r") as archive:
archive.extract(path=extract_dir, filter="fully_trusted")
with patch("tarfile.TarFile.extractall") as mock_extractall:
with TzstArchive(archive_path, "r") as archive:
archive.extract(path=extract_dir, filter="fully_trusted")
mock_extractall.assert_called_once()
call_args = mock_extractall.call_args
assert call_args[1]["filter"] == "fully_trusted"
mock_extractall.assert_called_once()
call_args = mock_extractall.call_args
assert call_args[1]["filter"] == "fully_trusted"
def test_none_filter_with_warning(self, sample_files, temp_dir, capsys):
"""Test None filter shows deprecation warning."""
@@ -117,14 +112,13 @@ class TestExtractionFilters:
# Test extraction with None filter
extract_dir = temp_dir / "extracted_none"
if EXTRACTION_FILTERS_SUPPORTED:
with patch("tarfile.TarFile.extractall") as mock_extractall:
with TzstArchive(archive_path, "r") as archive:
archive.extract(path=extract_dir, filter=None)
with patch("tarfile.TarFile.extractall") as mock_extractall:
with TzstArchive(archive_path, "r") as archive:
archive.extract(path=extract_dir, filter=None)
mock_extractall.assert_called_once()
call_args = mock_extractall.call_args
assert call_args[1]["filter"] is None
mock_extractall.assert_called_once()
call_args = mock_extractall.call_args
assert call_args[1]["filter"] is None
def test_custom_filter_function(self, sample_files, temp_dir):
"""Test custom filter function."""
@@ -147,36 +141,13 @@ class TestExtractionFilters:
# Test extraction with custom filter
extract_dir = temp_dir / "extracted_custom"
if EXTRACTION_FILTERS_SUPPORTED:
with patch("tarfile.TarFile.extractall") as mock_extractall:
with TzstArchive(archive_path, "r") as archive:
archive.extract(path=extract_dir, filter=custom_filter)
mock_extractall.assert_called_once()
call_args = mock_extractall.call_args
assert call_args[1]["filter"] == custom_filter
def test_filter_not_supported_warning(self, sample_files, temp_dir, capsys):
"""Test warning when filters not supported in older Python versions."""
archive_path = temp_dir / "test_warning.tzst"
file_paths = [f for f in sample_files if f.is_file()]
# Create archive
with TzstArchive(archive_path, "w") as archive:
for file_path in file_paths:
relative_path = file_path.relative_to(sample_files[0].parent)
archive.add(file_path, arcname=str(relative_path))
# Mock EXTRACTION_FILTERS_SUPPORTED to False
with patch("tzst.core.EXTRACTION_FILTERS_SUPPORTED", False):
extract_dir = temp_dir / "extracted_warning"
with patch("tarfile.TarFile.extractall") as mock_extractall:
with TzstArchive(archive_path, "r") as archive:
archive.extract(path=extract_dir, filter="tar")
archive.extract(path=extract_dir, filter=custom_filter)
# Check warning was printed
captured = capsys.readouterr()
assert "Warning: Extraction filters are not supported" in captured.out
mock_extractall.assert_called_once()
call_args = mock_extractall.call_args
assert call_args[1]["filter"] == custom_filter
def test_convenience_function_filter(self, sample_files, temp_dir):
"""Test filter parameter in extract_archive convenience function."""
@@ -190,17 +161,16 @@ class TestExtractionFilters:
archive.add(file_path, arcname=str(relative_path))
# Test extract_archive with different filters
if EXTRACTION_FILTERS_SUPPORTED:
for filter_type in ["data", "tar", "fully_trusted"]:
extract_dir = temp_dir / f"extracted_conv_{filter_type}"
for filter_type in ["data", "tar", "fully_trusted"]:
extract_dir = temp_dir / f"extracted_conv_{filter_type}"
# This should not raise an exception
extract_archive(archive_path, extract_dir, filter=filter_type)
# This should not raise an exception
extract_archive(archive_path, extract_dir, filter=filter_type)
# Verify files were extracted
assert extract_dir.exists()
extracted_files = list(extract_dir.rglob("*"))
assert len([f for f in extracted_files if f.is_file()]) > 0
# Verify files were extracted
assert extract_dir.exists()
extracted_files = list(extract_dir.rglob("*"))
assert len([f for f in extracted_files if f.is_file()]) > 0
class TestSecurityDocumentation:
@@ -208,20 +178,19 @@ class TestSecurityDocumentation:
def test_extract_method_has_security_warning(self):
"""Test that extract method has proper security warning in docstring."""
assert (
"Never extract archives from untrusted sources"
in TzstArchive.extract.__doc__
)
assert "filter" in TzstArchive.extract.__doc__
assert "data" in TzstArchive.extract.__doc__
docstring = TzstArchive.extract.__doc__
assert docstring is not None
assert "Never extract archives from untrusted sources" in docstring
assert "filter" in docstring
assert "data" in docstring
def test_extract_archive_has_security_warning(self):
"""Test that extract_archive function has proper security warning."""
assert (
"Never extract archives from untrusted sources" in extract_archive.__doc__
)
assert "path traversal attacks" in extract_archive.__doc__
assert "data" in extract_archive.__doc__
docstring = extract_archive.__doc__
assert docstring is not None
assert "Never extract archives from untrusted sources" in docstring
assert "path traversal attacks" in docstring
assert "data" in docstring
class TestSecurityEdgeCases:
@@ -241,15 +210,14 @@ class TestSecurityEdgeCases:
# Test extraction in streaming mode with filter
extract_dir = temp_dir / "extracted_streaming"
if EXTRACTION_FILTERS_SUPPORTED:
# This should work without errors
with TzstArchive(archive_path, "r", streaming=True) as archive:
archive.extract(path=extract_dir, filter="data")
# This should work without errors
with TzstArchive(archive_path, "r", streaming=True) as archive:
archive.extract(path=extract_dir, filter="data")
# Verify files were extracted
assert extract_dir.exists()
extracted_files = list(extract_dir.rglob("*"))
assert len([f for f in extracted_files if f.is_file()]) > 0
# Verify files were extracted
assert extract_dir.exists()
extracted_files = list(extract_dir.rglob("*"))
assert len([f for f in extracted_files if f.is_file()]) > 0
def test_filter_with_specific_member_extraction(self, sample_files, temp_dir):
"""Test filter when extracting specific members."""
@@ -265,14 +233,13 @@ class TestSecurityEdgeCases:
# Test extracting specific member with filter
extract_dir = temp_dir / "extracted_member"
if EXTRACTION_FILTERS_SUPPORTED and file_paths:
if file_paths:
with TzstArchive(archive_path, "r") as archive:
members = archive.getnames()
if members:
# Extract first member with data filter
archive.extract(
member=members[0], path=extract_dir, filter="data"
) # Verify file was extracted
archive.extract(member=members[0], path=extract_dir, filter="data")
# Verify file was extracted
assert extract_dir.exists()
extracted_file = extract_dir / members[0]
assert extracted_file.exists()