18 Commits
Author SHA1 Message Date
xixu-me f866f30b7e Bump version to 1.2.3
CI/CD / test (macos-latest, 3.12) (push) Waiting to run
CI/CD / test (macos-latest, 3.13) (push) Waiting to run
CI/CD / test (ubuntu-latest, 3.12) (push) Waiting to run
CI/CD / test (ubuntu-latest, 3.13) (push) Waiting to run
CI/CD / test (windows-latest, 3.12) (push) Waiting to run
CI/CD / test (windows-latest, 3.13) (push) Waiting to run
Updated the `__version__` in `__init__.py` from 1.2.2 to 1.2.3 to reflect the latest changes or release.
2025-06-04 21:39:18 +08:00
xixu-me b2eebdedb2 Simplify GitHub release notes in CI workflow
Updated the GitHub release creation step to use a simplified title format and removed detailed release notes. This change streamlines the release process by omitting installation instructions and commit history links.
2025-06-04 21:39:02 +08:00
xixu-me 1414138341 Update badges [skip ci] 2025-06-04 13:29:39 +00:00
xixu-me eb4d29e503 Bump version to 1.2.2
Updated the `__version__` in `__init__.py` from 1.2.1 to 1.2.2 to reflect the latest changes or release.
2025-06-04 21:27:09 +08:00
xixu-me 76963aaf9f Fix indentation in CI workflow script
Corrected the indentation in the Python script within the CI workflow to ensure proper execution of the version assignment and file writing logic.
2025-06-04 21:26:38 +08:00
xixu-me 8035a77c10 Bump version to 1.2.1
Updated the `__version__` in `__init__.py` from 1.2.0 to 1.2.1 to reflect the latest changes or release.
2025-06-04 21:18:57 +08:00
xixu-me d95f173308 Update PyInstaller command in CI workflow
Modified the PyInstaller command in the CI configuration to specify 'src/main.py' as the entry point for building binaries across macOS, Windows, and Linux ARM64 platforms.
2025-06-04 21:12:43 +08:00
xixu-me 637f1d2562 Update CI workflow and add main entry point
Modified the CI workflow to correctly specify the path to the main script for PyInstaller builds. Added a new standalone entry point in src/main.py to serve as the executable script for tzst.
2025-06-04 21:08:33 +08:00
xixu-me d2f11636be Revert "Add standalone entry point for tzst"
This reverts commit 69918b4be5.
2025-06-04 21:05:15 +08:00
xixu-me 69918b4be5 Add standalone entry point for tzst
Created a new main.py file as the standalone entry point for tzst, intended for PyInstaller builds. This script sets up the Python path and invokes the CLI main function.
2025-06-04 21:04:24 +08:00
xixu-me 7d688d420b Bump version to 1.2.0
Updated the `__version__` in `__init__.py` to reflect the new release version 1.2.0.
2025-06-04 20:54:49 +08:00
xixu-me 56a10bc038 Update CI workflow for macOS and binary verification
This commit updates the CI workflow to use macOS-14 for ARM-based runners, adds binary verification steps for Linux, macOS, and Windows, and improves release asset upload handling with success checks. Additionally, it refines the README badge update process for PyPI version.
2025-06-04 20:54:31 +08:00
xixu-me 1dd3cde654 Enhance CI/CD workflow for versioned tags
Updated the CI/CD pipeline to support versioned tags for releases. Added new jobs for building binaries across multiple platforms and architectures, creating release assets, and uploading them to GitHub. Improved conditional logic for triggering workflows based on tags and streamlined badge updates.
2025-06-04 20:42:48 +08:00
xixu-me a1de2e1855 Remove unused report configuration in Codecov
The 'report.exclude_labels' section was removed from the Codecov configuration as it is no longer needed. This simplifies the configuration file.
2025-06-04 20:06:18 +08:00
xixu-me 6397968a4d Improve conflict resolution in archive extraction
Updated the `extract_archive` function to handle string-based conflict resolution by converting it to an enum. Added fallback to `ConflictResolution.REPLACE` for invalid values, ensuring safer and more predictable behavior.
2025-06-04 19:27:41 +08:00
xixu-me cb5c198d16 Refactor tests and enhance coverage for conflict resolution and edge cases
- Removed outdated tests for missing lines in core.py.
- Added comprehensive tests for conflict resolution functionality, including various resolution strategies and edge cases.
- Improved error handling tests for archive creation and extraction processes.
- Enhanced unit tests for basic functionality and convenience functions with pytest markers.
- Introduced tests for unique filename generation and conflict handling scenarios.
- Added edge case tests for archive extraction and error conditions to improve overall test coverage.
2025-06-04 18:55:53 +08:00
xixu-me afe5d738a6 Merge pull request #5 from xixu-me/alert-autofix-5
Potential fix for code scanning alert no. 5: Workflow does not contain permissions
2025-06-02 22:02:55 +08:00
xixu-meandCopilot Autofix powered by AI 06eac113fa Potential fix for code scanning alert no. 5: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-06-02 21:46:34 +08:00
20 changed files with 1511 additions and 1437 deletions

No files matched your search

-5
View File
@@ -11,11 +11,6 @@ coverage:
threshold: 1%
informational: true
# Report coverage for all files, even if not touched in PR
report:
exclude_labels:
- "skip-coverage"
comment:
layout: "reach,diff,flags,tree"
behavior: default
+202 -8
View File
@@ -3,6 +3,8 @@ name: CI/CD
on:
push:
branches: [main, develop]
tags:
- "v*.*.*"
paths-ignore:
- "*.md"
- "LICENSE"
@@ -93,7 +95,7 @@ jobs:
publish:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'release' && github.event.action == 'published'
if: startsWith(github.ref, 'refs/tags/v')
environment:
name: pypi
url: https://pypi.org/p/tzst
@@ -109,10 +111,200 @@ jobs:
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
build-binaries:
needs: test
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: read
strategy:
matrix:
include:
# Linux architectures
- os: ubuntu-latest
os_name: linux
arch: x86_64
python-version: "3.12"
- os: ubuntu-latest
os_name: linux
arch: aarch64
python-version: "3.12"
cross_compile: true
# Windows architectures
- os: windows-latest
os_name: windows
arch: amd64
python-version: "3.12"
- os: windows-latest
os_name: windows
arch: arm64
python-version: "3.12"
cross_compile: true # macOS architectures
- os: macos-13 # Intel-based runner
os_name: macos
arch: x86_64
python-version: "3.12"
- os: macos-14 # ARM-based runner (M1/M2)
os_name: macos
arch: arm64
python-version: "3.12"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Extract version from tag
id: version
shell: bash
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .
pip install pyinstaller
- name: Build binary
run: |
python -m PyInstaller --onefile --name tzst --console src/main.py
- name: Verify binary (Linux/macOS)
if: matrix.os != 'windows-latest'
run: |
if [ ! -f "dist/tzst" ]; then
echo "Binary build failed!"
exit 1
fi
- name: Verify binary (Windows)
if: matrix.os == 'windows-latest'
run: |
if (-not (Test-Path "dist/tzst.exe")) {
Write-Host "Binary build failed!"
exit 1
}
- name: Build binary for ARM64 on macOS
if: matrix.os == 'macos-14' && matrix.arch == 'arm64'
run: |
python -m PyInstaller --onefile --name tzst --console --target-arch arm64 src/main.py
- name: Build binary for ARM64 on Windows
if: matrix.os == 'windows-latest' && matrix.arch == 'arm64'
run: |
python -m PyInstaller --onefile --name tzst --console --target-arch arm64 src/main.py
- name: Setup cross-compilation for Linux ARM64
if: matrix.os == 'ubuntu-latest' && matrix.arch == 'aarch64'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu
- name: Build binary for ARM64 on Linux
if: matrix.os == 'ubuntu-latest' && matrix.arch == 'aarch64'
env:
CC: aarch64-linux-gnu-gcc
run: |
python -m PyInstaller --onefile --name tzst --console --target-arch aarch64 src/main.py
- name: Prepare archive contents (Linux/macOS)
if: matrix.os != 'windows-latest'
run: |
mkdir -p archive
cp dist/tzst archive/
cp README.md archive/
cp LICENSE archive/
- name: Prepare archive contents (Windows)
if: matrix.os == 'windows-latest'
run: |
New-Item -ItemType Directory -Path archive -Force
Copy-Item dist/tzst.exe archive/
Copy-Item README.md archive/
Copy-Item LICENSE archive/
- name: Create zip archive (Linux/macOS)
if: matrix.os != 'windows-latest'
run: |
cd archive
zip -r ../tzst-v${{ steps.version.outputs.version }}-${{ matrix.os_name }}-${{ matrix.arch }}.zip .
- name: Create zip archive (Windows)
if: matrix.os == 'windows-latest'
run: |
cd archive
Compress-Archive -Path * -DestinationPath ../tzst-v${{ steps.version.outputs.version }}-${{ matrix.os_name }}-${{ matrix.arch }}.zip
- name: Upload binary artifacts
uses: actions/upload-artifact@v4
with:
name: binary-${{ matrix.os_name }}-${{ matrix.arch }}
path: tzst-v${{ steps.version.outputs.version }}-${{ matrix.os_name }}-${{ matrix.arch }}.zip
create-release:
needs: [publish, build-binaries]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Extract version from tag
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download all binary artifacts
uses: actions/download-artifact@v4
with:
pattern: binary-*
merge-multiple: true
- name: Create GitHub Release
run: |
gh release create ${{ github.ref_name }} \
--title "tzst ${{ steps.version.outputs.version }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Release Assets
run: |
echo "Available files:"
ls -la tzst-v${{ steps.version.outputs.version }}-*.zip 2>/dev/null || echo "No zip files found"
success=0
for file in tzst-v${{ steps.version.outputs.version }}-*.zip; do
if [ -f "$file" ]; then
echo "Uploading $file"
if gh release upload ${{ github.ref_name }} "$file" --clobber; then
echo "Successfully uploaded $file"
success=$((success + 1))
else
echo "Failed to upload $file"
fi
fi
done
if [ $success -eq 0 ]; then
echo "Warning: No files were successfully uploaded"
else
echo "Successfully uploaded $success file(s)"
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update_badges:
needs: [build, publish]
if: always() && needs.build.result == 'success'
if: always() && needs.build.result == 'success' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
@@ -120,11 +312,6 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }}
- name: Wait if this is after a publish
if: github.event_name == 'release' && github.event.action == 'published'
run: sleep 30
- name: Set up Python
uses: actions/setup-python@v5
@@ -151,6 +338,13 @@ jobs:
- name: Update README badge
run: |
echo "Updating PyPI badge with version: ${{ steps.pypi_version.outputs.version }}"
# Update PyPI version badge in README.md
if [ -f "README.md" ]; then
# Replace PyPI version badge
sed -i 's|https://img.shields.io/pypi/v/tzst[^)]*|https://img.shields.io/pypi/v/tzst?label=PyPI\&color=blue|g' README.md || true
# Update version in badge alt text if exists
sed -i 's|PyPI - Version[^]]*|PyPI - Version|g' README.md || true
fi
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
@@ -159,4 +353,4 @@ jobs:
with:
commit_message: "Update badges [skip ci]"
file_pattern: README.md
branch: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}
branch: main
+3 -7
View File
@@ -1,12 +1,6 @@
name: Publish Documentation
on:
push:
branches:
- main
pull_request:
branches:
- main
on: workflow_dispatch
jobs:
build-and-deploy-docs:
@@ -67,6 +61,8 @@ jobs:
docs-quality-check:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
+1 -1
View File
@@ -3,7 +3,7 @@
[![codecov](https://codecov.io/gh/xixu-me/tzst/graph/badge.svg?token=2AIN1559WU)](https://codecov.io/gh/xixu-me/tzst)
[![CodeQL](https://github.com/xixu-me/tzst/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/xixu-me/tzst/actions/workflows/github-code-scanning/codeql)
[![CI/CD](https://github.com/xixu-me/tzst/actions/workflows/ci.yml/badge.svg)](https://github.com/xixu-me/tzst/actions/workflows/ci.yml)
[![PyPI - Version](https://img.shields.io/pypi/v/tzst)](https://pypi.org/project/tzst/)
[![PyPI - Version](https://img.shields.io/pypi/v/tzst?label=PyPI&color=blue)](https://pypi.org/project/tzst/)
[![GitHub License](https://img.shields.io/github/license/xixu-me/tzst)](LICENSE)
[![Sponsor](https://img.shields.io/badge/Sponsor-violet)](https://xi-xu.me/#sponsorships)
+11 -10
View File
@@ -24,7 +24,14 @@ classifiers = [
dependencies = ["zstandard>=0.19.0,<1.0.0"]
[project.optional-dependencies]
dev = ["pytest>=7.0.0", "pytest-cov>=4.0.0", "ruff>=0.1.0"]
dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"ruff>=0.1.0",
"pre-commit>=3.6.0",
"build>=1.0.0",
"twine>=4.0.0",
]
[project.urls]
Homepage = "https://github.com/xixu-me/tzst"
@@ -42,13 +49,7 @@ path = "src/tzst/__init__.py"
packages = ["src/tzst"]
[tool.hatch.build.targets.sdist]
include = [
"src",
"tests",
"README.md",
"LICENSE",
"CONTRIBUTING.md",
]
include = ["src", "tests", "README.md", "LICENSE", "CONTRIBUTING.md"]
[tool.pytest.ini_options]
testpaths = ["tests"]
@@ -76,8 +77,8 @@ select = [
"RUF", # ruff specific rules
]
ignore = [
"E501", # line too long
"E501", # line too long - handled by formatter
"C901", # function too complex - accepted for core functionality
]
fixable = ["ALL"]
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env python3
"""Standalone entry point for tzst - used for PyInstaller builds."""
import os
import sys
# Add the src directory to the Python path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
from tzst.cli import main
if __name__ == "__main__":
sys.exit(main())
+6 -2
View File
@@ -1,6 +1,10 @@
"""tzst - The next-generation Python library engineered for modern archive management, leveraging cutting-edge Zstandard compression to deliver superior performance, security, and reliability."""
"""tzst - The next-generation Python library engineered for modern archive management.
__version__ = "1.1.1"
Leveraging cutting-edge Zstandard compression to deliver superior performance,
security, and reliability.
"""
__version__ = "1.2.3"
from .core import (
TzstArchive,
+136 -1
View File
@@ -6,10 +6,57 @@ from pathlib import Path
from typing import Literal, cast
from . import __version__
from .core import create_archive, extract_archive, list_archive, test_archive
from .core import (
ConflictResolution,
create_archive,
extract_archive,
list_archive,
test_archive,
)
from .exceptions import TzstArchiveError, TzstDecompressionError
def _interactive_conflict_callback(target_path: Path) -> ConflictResolution:
"""Interactive callback for handling file conflicts in CLI.
Args:
target_path: Path of the conflicting file
Returns:
ConflictResolution: User's choice for handling the conflict
"""
print(f"\nFile already exists: {target_path}")
print("Choose an action:")
print(" [R] Replace")
print(" [N] Do not replace (skip)")
print(" [A] Replace all")
print(" [S] Skip all")
print(" [U] Auto-rename all")
print(" [X] Exit")
while True:
try:
choice = input("Enter choice [R/N/A/S/U/X]: ").strip().upper()
if choice == "R":
return ConflictResolution.REPLACE
elif choice == "N":
return ConflictResolution.SKIP
elif choice == "A":
return ConflictResolution.REPLACE_ALL
elif choice == "S":
return ConflictResolution.SKIP_ALL
elif choice == "U":
return ConflictResolution.AUTO_RENAME_ALL
elif choice == "X":
return ConflictResolution.EXIT
else:
print("Invalid choice. Please enter R, N, A, S, U, or X.")
except (EOFError, KeyboardInterrupt):
print("\nOperation cancelled by user")
return ConflictResolution.EXIT
def print_banner() -> None:
"""Print the version and copyright banner.
@@ -299,12 +346,30 @@ def cmd_extract_full(args) -> int:
Literal["data", "tar", "fully_trusted"], getattr(args, "filter", "data")
)
# Handle conflict resolution parameters
conflict_resolution_str = getattr(args, "conflict_resolution", "ask")
interactive_flag = getattr(args, "interactive", False)
# If --interactive is specified, use "ask" regardless of --conflict-resolution
if interactive_flag:
conflict_resolution_str = "ask"
# Convert string to ConflictResolution enum
conflict_resolution = ConflictResolution(conflict_resolution_str)
# Set up interactive callback if needed
interactive_callback = None
if conflict_resolution == ConflictResolution.ASK:
interactive_callback = _interactive_conflict_callback
print(f"Extracting from: {archive_path}")
print(f"Output directory: {output_dir}")
if streaming:
print("Using streaming mode (memory efficient)")
if filter_type != "data":
print(f"Using security filter: {filter_type}")
if conflict_resolution != ConflictResolution.REPLACE:
print(f"Conflict resolution: {conflict_resolution.value}")
extract_archive(
archive_path,
@@ -313,6 +378,8 @@ def cmd_extract_full(args) -> int:
flatten=False,
streaming=streaming,
filter=filter_type,
conflict_resolution=conflict_resolution,
interactive_callback=interactive_callback,
)
print("Extraction completed successfully")
return 0
@@ -377,10 +444,28 @@ def cmd_extract_flat(args) -> int:
Literal["data", "tar", "fully_trusted"], getattr(args, "filter", "data")
)
# Handle conflict resolution parameters
conflict_resolution_str = getattr(args, "conflict_resolution", "ask")
interactive_flag = getattr(args, "interactive", False)
# If --interactive is specified, use "ask" regardless of --conflict-resolution
if interactive_flag:
conflict_resolution_str = "ask"
# Convert string to ConflictResolution enum
conflict_resolution = ConflictResolution(conflict_resolution_str)
# Set up interactive callback if needed
interactive_callback = None
if conflict_resolution == ConflictResolution.ASK:
interactive_callback = _interactive_conflict_callback
print(f"Extracting from: {archive_path}")
print(f"Output directory: {output_dir}")
if filter_type != "data":
print(f"Using security filter: {filter_type}")
if conflict_resolution != ConflictResolution.REPLACE:
print(f"Conflict resolution: {conflict_resolution.value}")
extract_archive(
archive_path,
@@ -389,6 +474,8 @@ def cmd_extract_flat(args) -> int:
flatten=True,
streaming=streaming,
filter=filter_type,
conflict_resolution=conflict_resolution,
interactive_callback=interactive_callback,
)
print("Extraction completed successfully")
return 0
@@ -704,6 +791,30 @@ documentation:
"'fully_trusted' honors all metadata"
),
)
parser_extract.add_argument(
"--conflict-resolution",
choices=[
"replace",
"skip",
"replace_all",
"skip_all",
"auto_rename",
"auto_rename_all",
"ask",
],
default="ask",
help=(
"How to handle file conflicts during extraction (default: ask). "
"'ask' prompts for each conflict, 'replace' overwrites existing files, "
"'skip' skips existing files, 'auto_rename' creates new names. "
"Adding '_all' applies the action to all subsequent conflicts."
),
)
parser_extract.add_argument(
"--interactive",
action="store_true",
help="Enable interactive conflict resolution prompts",
)
parser_extract.set_defaults(func=cmd_extract_full)
# Extract flat command
@@ -734,6 +845,30 @@ documentation:
"'fully_trusted' honors all metadata"
),
)
parser_extract_flat.add_argument(
"--conflict-resolution",
choices=[
"replace",
"skip",
"replace_all",
"skip_all",
"auto_rename",
"auto_rename_all",
"ask",
],
default="ask",
help=(
"How to handle file conflicts during extraction (default: ask). "
"'ask' prompts for each conflict, 'replace' overwrites existing files, "
"'skip' skips existing files, 'auto_rename' creates new names. "
"Adding '_all' applies the action to all subsequent conflicts."
),
)
parser_extract_flat.add_argument(
"--interactive",
action="store_true",
help="Enable interactive conflict resolution prompts",
)
parser_extract_flat.set_defaults(func=cmd_extract_flat)
# List command
+263 -8
View File
@@ -6,6 +6,7 @@ import tarfile
import tempfile
import time
from collections.abc import Callable, Sequence
from enum import Enum
from pathlib import Path
from typing import BinaryIO
@@ -14,6 +15,125 @@ import zstandard as zstd
from .exceptions import TzstArchiveError, TzstDecompressionError
class ConflictResolution(Enum):
"""Enum for conflict resolution strategies."""
REPLACE = "replace"
SKIP = "skip"
REPLACE_ALL = "replace_all"
SKIP_ALL = "skip_all"
AUTO_RENAME = "auto_rename"
AUTO_RENAME_ALL = "auto_rename_all"
EXIT = "exit"
ASK = "ask"
class ConflictResolutionState:
"""State management for conflict resolution during extraction."""
def __init__(self, initial_resolution: ConflictResolution | None = None):
self.continue_extraction = True
self.global_resolution = initial_resolution
# If initial resolution is EXIT, set continue_extraction to False
if initial_resolution == ConflictResolution.EXIT:
self.continue_extraction = False
@property
def current_resolution(self) -> ConflictResolution | None:
"""Get the current resolution state."""
return self.global_resolution
def should_continue(self) -> bool:
"""Check if extraction should continue."""
return self.continue_extraction
@property
def apply_to_all(self) -> bool:
"""Check if the current resolution applies to all future conflicts."""
return self.global_resolution in (
ConflictResolution.REPLACE_ALL,
ConflictResolution.SKIP_ALL,
ConflictResolution.AUTO_RENAME_ALL,
)
def update_resolution(self, resolution: ConflictResolution) -> None:
"""Update the global resolution state."""
if resolution == ConflictResolution.EXIT:
self.continue_extraction = False
self.global_resolution = resolution
elif resolution in (
ConflictResolution.REPLACE_ALL,
ConflictResolution.SKIP_ALL,
ConflictResolution.AUTO_RENAME_ALL,
):
self.global_resolution = resolution
def _get_unique_filename(file_path: Path) -> Path:
"""Generate a unique filename by appending a number if the file exists."""
if not file_path.exists():
return file_path
parent = file_path.parent
stem = file_path.stem
suffix = file_path.suffix
counter = 1
while True:
new_name = f"{stem}_{counter}{suffix}"
new_path = parent / new_name
if not new_path.exists():
return new_path
counter += 1
def _handle_file_conflict(
target_path: Path,
resolution: ConflictResolution | str,
interactive_callback: Callable[[Path], ConflictResolution] | None = None,
) -> tuple[ConflictResolution, Path | None]:
"""
Handle file conflicts during extraction.
Args:
target_path: The path where a conflict occurred
resolution: The conflict resolution strategy
interactive_callback: Optional callback for interactive resolution
Returns:
Tuple of (actual_resolution, final_path)"""
# Convert string resolution to enum if needed
if isinstance(resolution, str):
try:
resolution = ConflictResolution(resolution)
except ValueError:
# Invalid string, fallback to ASK for interactive handling
resolution = ConflictResolution.ASK
if resolution == ConflictResolution.ASK:
if interactive_callback:
resolution = interactive_callback(target_path)
else:
# No callback provided, default to REPLACE for consistency with tests
resolution = ConflictResolution.REPLACE
if resolution in (ConflictResolution.REPLACE, ConflictResolution.REPLACE_ALL):
return resolution, target_path
elif resolution in (ConflictResolution.SKIP, ConflictResolution.SKIP_ALL):
return resolution, None
elif resolution in (
ConflictResolution.AUTO_RENAME,
ConflictResolution.AUTO_RENAME_ALL,
):
unique_path = _get_unique_filename(target_path)
return resolution, unique_path
elif resolution == ConflictResolution.EXIT:
return resolution, None
else:
# Unknown resolution, default to REPLACE for robustness
return ConflictResolution.REPLACE, target_path
class TzstArchive:
"""A class for handling .tzst/.tar.zst archives."""
@@ -620,6 +740,8 @@ def extract_archive(
flatten: bool = False,
streaming: bool = False,
filter: str | Callable | None = "data",
conflict_resolution: ConflictResolution | str = ConflictResolution.REPLACE,
interactive_callback: Callable[[Path], ConflictResolution] | None = None,
) -> None:
"""
Extract files from a .tzst archive.
@@ -631,21 +753,31 @@ def extract_archive(
flatten: If True, extract without directory structure
streaming: If True, use streaming mode (memory efficient for large archives)
filter: Extraction filter for security. Can be:
- 'data': Safe filter for cross-platform data archives (default, recommended)
- 'data': Safe filter for cross-platform data archives (default)
- 'tar': Honor most tar features but block dangerous ones
- 'fully_trusted': Honor all metadata (use only for trusted archives)
- None: Use default behavior (may show deprecation warning in Python 3.12+)
- None: Use default behavior (may show deprecation warning)
- callable: Custom filter function
conflict_resolution: How to handle file conflicts during extraction
interactive_callback: Function to call for interactive conflict resolution
Warning:
Never extract archives from untrusted sources without proper filtering.
The 'data' filter is recommended for most use cases as it prevents
Never extract archives from untrusted sources without proper filtering. The 'data' filter is recommended for most use cases as it prevents
dangerous security issues like path traversal attacks.
See Also:
See Also:
:meth:`TzstArchive.extract`: Method for extracting from an open archive
"""
with TzstArchive(archive_path, "r", streaming=streaming) as archive:
# Convert string resolution to enum if needed
if isinstance(conflict_resolution, str):
try:
conflict_resolution = ConflictResolution(conflict_resolution)
except ValueError:
conflict_resolution = ConflictResolution.REPLACE
state = ConflictResolutionState(conflict_resolution)
if flatten:
# Extract files without directory structure
extract_dir = Path(extract_path)
@@ -657,20 +789,143 @@ def extract_archive(
member_list = archive.getmembers()
for member in member_list:
if not state.should_continue():
break
if member.isfile():
# Extract to flat directory
filename = Path(member.name).name
target_path = extract_dir / filename
# Handle conflicts
if target_path.exists():
current_resolution = (
state.global_resolution or conflict_resolution
)
actual_resolution, final_path = _handle_file_conflict(
target_path, current_resolution, interactive_callback
)
state.update_resolution(actual_resolution)
if actual_resolution in (
ConflictResolution.SKIP,
ConflictResolution.SKIP_ALL,
):
continue
elif actual_resolution == ConflictResolution.EXIT:
break
target_path = final_path
fileobj = archive.extractfile(member)
if fileobj:
with open(extract_dir / filename, "wb") as f:
with open(target_path, "wb") as f:
f.write(fileobj.read())
else:
# Extract with full directory structure
if members:
for member in members:
archive.extract(member, extract_path, filter=filter)
if not state.should_continue():
break
target_path = Path(extract_path) / member
# Handle conflicts
if target_path.exists():
current_resolution = (
state.global_resolution or conflict_resolution
)
actual_resolution, final_path = _handle_file_conflict(
target_path, current_resolution, interactive_callback
)
state.update_resolution(actual_resolution)
if actual_resolution in (
ConflictResolution.SKIP,
ConflictResolution.SKIP_ALL,
):
continue
elif actual_resolution == ConflictResolution.EXIT:
break
# For AUTO_RENAME, we need to adjust the member path
if actual_resolution in (
ConflictResolution.AUTO_RENAME,
ConflictResolution.AUTO_RENAME_ALL,
):
# Create parent directories for renamed file
final_path.parent.mkdir(parents=True, exist_ok=True)
# Extract to temporary location, then move
temp_extract_path = Path(tempfile.mkdtemp())
try:
archive.extract(
member, temp_extract_path, filter=filter
)
temp_file = temp_extract_path / member
temp_file.rename(final_path)
finally:
# Clean up temp directory
import shutil
shutil.rmtree(temp_extract_path, ignore_errors=True)
else:
archive.extract(member, extract_path, filter=filter)
else:
archive.extract(member, extract_path, filter=filter)
else:
archive.extract(path=extract_path, filter=filter)
# For extractall, we need a different approach
# We'll extract to a temp location and handle conflicts file by file
temp_extract_path = Path(tempfile.mkdtemp())
try:
archive.extractall(temp_extract_path, filter=filter)
# Move files with conflict resolution
for temp_file in temp_extract_path.rglob("*"):
if not state.should_continue():
break
if temp_file.is_file():
rel_path = temp_file.relative_to(temp_extract_path)
target_path = Path(extract_path) / rel_path
# Create parent directories
target_path.parent.mkdir(
parents=True, exist_ok=True
) # Handle conflicts
if target_path.exists():
current_resolution = (
state.global_resolution or conflict_resolution
)
actual_resolution, final_path = _handle_file_conflict(
target_path,
current_resolution,
interactive_callback,
)
state.update_resolution(actual_resolution)
if actual_resolution in (
ConflictResolution.SKIP,
ConflictResolution.SKIP_ALL,
):
continue
elif actual_resolution == ConflictResolution.EXIT:
break
target_path = final_path
# Handle file replacement on Windows
if target_path and target_path.exists():
if actual_resolution in (
ConflictResolution.REPLACE,
ConflictResolution.REPLACE_ALL,
):
target_path.unlink() # Remove existing file
if target_path:
temp_file.rename(target_path)
finally:
# Clean up temp directory
import shutil
shutil.rmtree(temp_extract_path, ignore_errors=True)
def list_archive(
+134 -1
View File
@@ -17,6 +17,7 @@ from tzst.cli import (
)
@pytest.mark.cli
class TestUtilityFunctions:
"""Test CLI utility functions."""
@@ -892,7 +893,7 @@ class TestCLIRealWorldScenarios:
# Test error operation returns non-zero
result = main(["l", "nonexistent_archive.tzst"])
assert result != 0 # Error should return non-zero
assert result != 0
class TestCLISecurityFilterParsing:
@@ -2289,3 +2290,135 @@ class TestCLIListingFunctionsCoverage:
assert "2 files" in captured.out
assert "2 directories" in captured.out
assert "300.0 B" in captured.out # Total size of files
class TestCLIEdgeCasesExtended:
"""Additional edge case tests for CLI functionality."""
def test_validate_files_os_error_handling(self, temp_dir):
"""Test OSError handling in validate_files function."""
from unittest.mock import patch
from tzst.cli import _validate_files
# Create a test file
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
# Mock Path.exists to raise OSError
with patch("pathlib.Path.exists", side_effect=OSError("Permission denied")):
# Should handle OSError gracefully and continue
try:
_validate_files([test_file])
except OSError:
pass # Expected to be caught and handled
def test_windows_specific_cli_functionality(self, temp_dir):
"""Test Windows-specific CLI functionality."""
import sys
if sys.platform != "win32":
pytest.skip("Windows-specific test")
# Test Windows reserved names
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
# Create archive
result = main(["a", str(archive_path), str(test_file)])
assert result == 0
# Test with Windows path separators
windows_style_path = str(archive_path).replace("/", "\\")
result = main(["l", windows_style_path])
assert result == 0
def test_compression_level_boundary_values(self, temp_dir):
"""Test compression level boundary values."""
test_file = temp_dir / "test.txt"
test_file.write_text("test content for compression")
# Test minimum compression level
archive_path_min = temp_dir / "test_min.tzst"
result = main(["a", str(archive_path_min), str(test_file), "-c", "1"])
assert result == 0
# Test maximum compression level
archive_path_max = temp_dir / "test_max.tzst"
result = main(["a", str(archive_path_max), str(test_file), "-c", "22"])
assert result == 0
def test_output_directory_creation_edge_cases(self, temp_dir):
"""Test output directory creation edge cases."""
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
# Create archive
result = main(["a", str(archive_path), str(test_file)])
assert result == 0
# Test extraction to nested directory that doesn't exist
nested_extract_dir = temp_dir / "level1" / "level2" / "level3"
result = main(["x", str(archive_path), "-o", str(nested_extract_dir)])
assert result == 0
# Verify directory was created
assert nested_extract_dir.exists()
def test_special_file_handling_edge_cases(self, temp_dir):
"""Test special file handling edge cases."""
# Create files with special characteristics
empty_file = temp_dir / "empty.txt"
empty_file.touch()
whitespace_file = temp_dir / "whitespace.txt"
whitespace_file.write_text(" \n\t\n ")
binary_file = temp_dir / "binary.bin"
binary_file.write_bytes(b"\x00\x01\x02\x03\x04\x05")
archive_path = temp_dir / "special.tzst"
# Create archive with special files
result = main(
[
"a",
str(archive_path),
str(empty_file),
str(whitespace_file),
str(binary_file),
]
)
assert result == 0
# Extract and verify
extract_dir = temp_dir / "extracted"
result = main(["x", str(archive_path), "-o", str(extract_dir)])
assert result == 0
def test_performance_edge_cases(self, temp_dir):
"""Test performance-related edge cases."""
# Create many small files
files = []
for i in range(20): # Create 20 small files
file_path = temp_dir / f"small_{i:03d}.txt"
file_path.write_text(f"Content of file {i}")
files.append(file_path)
archive_path = temp_dir / "many_files.tzst"
# Create archive with many files
file_args = [str(f) for f in files]
result = main(["a", str(archive_path), *file_args])
assert result == 0
# Test listing (should handle many files efficiently)
result = main(["l", str(archive_path)])
assert result == 0
# Test extraction
extract_dir = temp_dir / "extracted_many"
result = main(["x", str(archive_path), "-o", str(extract_dir)])
assert result == 0
+11
View File
@@ -7,6 +7,17 @@ from pathlib import Path
import pytest
def pytest_configure(config):
"""Configure pytest with custom markers."""
config.addinivalue_line("markers", "unit: Unit tests")
config.addinivalue_line("markers", "integration: Integration tests")
config.addinivalue_line("markers", "cli: CLI interface tests")
config.addinivalue_line("markers", "platform: Platform-specific tests")
config.addinivalue_line("markers", "windows: Windows-specific tests")
config.addinivalue_line("markers", "unix: Unix/Linux-specific tests")
config.addinivalue_line("markers", "slow: Slow running tests")
@pytest.fixture
def temp_dir():
"""Create a temporary directory for tests."""
-405
View File
@@ -1,405 +0,0 @@
"""Tests to cover missing lines in CLI and improve overall coverage."""
import sys
from unittest.mock import patch
import pytest
from tzst.cli import _validate_files, main
class TestCLIMissingLines:
"""Test specific missing lines in CLI for improved coverage."""
def test_validate_files_os_error_handling(self, temp_dir):
"""Test OSError handling in validate_files function."""
# Create a test file
test_file = temp_dir / "test.txt"
test_file.write_text("test content") # Mock Path.exists to raise OSError
with patch(
"pathlib.Path.exists", side_effect=OSError("Permission denied")
): # Should handle OSError gracefully and continue
try:
_validate_files([test_file])
except OSError:
pass # Expected to be caught and handled
def test_main_function_edge_cases(self, temp_dir):
"""Test main function edge cases for missing line coverage."""
# Test with minimal arguments that might hit edge cases
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst" # Create archive
result = main(["a", str(archive_path), str(test_file)])
assert result == 0
# Test version command through main
with patch("sys.exit"):
try:
main(["--version"])
except SystemExit:
pass
# Test help command variations
with patch("sys.exit"):
try:
main(["--help"])
except SystemExit:
pass
def test_command_line_argument_edge_cases(self, temp_dir):
"""Test command line argument edge cases."""
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
# Test with various argument combinations that might hit missing lines
archive_path = temp_dir / "test.tzst"
# Create archive with specific compression level
result = main(["a", str(archive_path), str(test_file), "-c", "1"])
assert result == 0
# Test list with streaming
result = main(["l", str(archive_path), "--streaming"])
assert result == 0 # Test extract with specific options
extract_dir = temp_dir / "extracted"
result = main(["x", str(archive_path), "-o", str(extract_dir)])
assert result == 0
def test_error_handling_edge_cases(self, temp_dir):
"""Test error handling edge cases in CLI."""
# Test with invalid archive path
invalid_path = temp_dir / "nonexistent" / "test.tzst"
result = main(["l", str(invalid_path)])
assert result == 1
# Test with invalid compression level - should return argparse error code 2
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
result = main(["a", str(archive_path), str(test_file), "-c", "50"])
assert result == 2
def test_filter_option_edge_cases(self, temp_dir):
"""Test filter option edge cases."""
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
# Create archive
result = main(["a", str(archive_path), str(test_file)])
assert result == 0
# Test extract with different filters
for filter_type in ["data", "tar", "fully_trusted"]:
extract_dir = temp_dir / f"extracted_{filter_type}"
result = main(
[
"x",
str(archive_path),
"-o",
str(extract_dir),
"--filter",
filter_type,
]
)
assert result == 0
def test_atomic_operation_edge_cases(self, temp_dir):
"""Test atomic operation edge cases."""
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
# Test with --no-atomic flag
result = main(["a", str(archive_path), str(test_file), "--no-atomic"])
assert result == 0
# Verify archive was created
assert archive_path.exists()
def test_verbose_output_edge_cases(self, temp_dir, capsys):
"""Test verbose output edge cases."""
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst" # Create archive
result = main(["a", str(archive_path), str(test_file)])
assert result == 0
# Test verbose list
result = main(["l", str(archive_path), "-v"])
assert result == 0
captured = capsys.readouterr()
assert len(captured.out) > 0
def test_command_validation_edge_cases(self):
"""Test command validation edge cases."""
# Test with empty arguments
result = main([])
assert result == 1
# Test with invalid command - should return argparse error code 2
result = main(["invalid_command"])
assert result == 2
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test")
def test_windows_specific_functionality(self, temp_dir):
"""Test Windows-specific functionality."""
# Test Windows reserved names
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
# Create archive
result = main(["a", str(archive_path), str(test_file)])
assert result == 0
# Test with Windows path separators
windows_style_path = str(archive_path).replace("/", "\\")
result = main(["l", windows_style_path])
assert result == 0
def test_streaming_mode_edge_cases(self, temp_dir):
"""Test streaming mode edge cases."""
# Create a larger file for streaming tests
large_file = temp_dir / "large.txt"
large_file.write_text("x" * 10000) # 10KB file
archive_path = temp_dir / "streaming.tzst"
# Create archive
result = main(["a", str(archive_path), str(large_file)])
assert result == 0
# Test all commands with streaming
result = main(["l", str(archive_path), "--streaming"])
assert result == 0
result = main(["t", str(archive_path), "--streaming"])
assert result == 0
extract_dir = temp_dir / "extracted_streaming"
result = main(["x", str(archive_path), "-o", str(extract_dir), "--streaming"])
assert result == 0
def test_compression_level_boundary_values(self, temp_dir):
"""Test compression level boundary values."""
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
# Test minimum compression level
archive_path_min = temp_dir / "min_compression.tzst"
result = main(["a", str(archive_path_min), str(test_file), "-c", "1"])
assert result == 0
# Test maximum compression level
archive_path_max = temp_dir / "max_compression.tzst"
result = main(["a", str(archive_path_max), str(test_file), "-c", "22"])
assert result == 0
def test_output_directory_creation_edge_cases(self, temp_dir):
"""Test output directory creation edge cases."""
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
# Create archive
result = main(["a", str(archive_path), str(test_file)])
assert result == 0
# Test extraction to nested directory that doesn't exist
nested_extract_dir = temp_dir / "level1" / "level2" / "level3"
result = main(["x", str(archive_path), "-o", str(nested_extract_dir)])
assert result == 0
# Verify directory was created
assert nested_extract_dir.exists()
def test_special_file_handling_edge_cases(self, temp_dir):
"""Test special file handling edge cases."""
# Create files with special characteristics
empty_file = temp_dir / "empty.txt"
empty_file.touch()
binary_file = temp_dir / "binary.bin"
binary_file.write_bytes(b"\x00\x01\x02\x03\xff")
unicode_file = temp_dir / "unicode.txt"
unicode_file.write_text("Hello 世界 🌍", encoding="utf-8")
archive_path = temp_dir / "special.tzst"
# Create archive with special files
result = main(
[
"a",
str(archive_path),
str(empty_file),
str(binary_file),
str(unicode_file),
]
)
assert result == 0
# Test list and extract
result = main(["l", str(archive_path)])
assert result == 0
extract_dir = temp_dir / "extracted_special"
result = main(["x", str(archive_path), "-o", str(extract_dir)])
assert result == 0
class TestPlatformSpecificMissingLines:
"""Test platform-specific functionality to improve coverage."""
@pytest.mark.skipif(
sys.platform != "win32", reason="Windows-specific functionality"
)
def test_windows_long_path_edge_cases(self, temp_dir):
"""Test Windows long path handling edge cases."""
# Create a very deep directory structure
deep_dir = temp_dir
for i in range(10):
deep_dir = deep_dir / f"very_long_directory_name_{i}"
deep_dir.mkdir(parents=True, exist_ok=True)
deep_file = deep_dir / "deep_file.txt"
deep_file.write_text("Content in deeply nested file")
archive_path = temp_dir / "deep.tzst"
# Test archiving deep structure
result = main(["a", str(archive_path), str(deep_file)])
assert result == 0
# Test extraction
extract_dir = temp_dir / "extracted_deep"
result = main(["x", str(archive_path), "-o", str(extract_dir)])
assert result == 0
@pytest.mark.skipif(
sys.platform != "win32", reason="Windows-specific functionality"
)
def test_windows_reserved_names_edge_cases(self, temp_dir):
"""Test Windows reserved names edge cases."""
# Test with files that have problematic names on Windows
normal_file = temp_dir / "normal.txt"
normal_file.write_text("normal content")
# File with trailing space (problematic on Windows)
space_file = temp_dir / "file_with_space .txt"
space_file.write_text("space content")
archive_path = temp_dir / "reserved.tzst"
# Create archive
result = main(["a", str(archive_path), str(normal_file), str(space_file)])
assert result == 0
def test_unicode_handling_edge_cases(self, temp_dir):
"""Test unicode handling edge cases."""
# Create files with various unicode content
files_to_create = [
("chinese.txt", "你好世界"),
("emoji.txt", "🎉🌟💫"),
("mixed.txt", "Hello 世界! 🌍 Мир"),
("special_chars.txt", "àáâãäåæçèéêë"),
]
created_files = []
for filename, content in files_to_create:
file_path = temp_dir / filename
file_path.write_text(content, encoding="utf-8")
created_files.append(file_path)
archive_path = temp_dir / "unicode.tzst" # Create archive
file_args = [str(f) for f in created_files]
result = main(["a", str(archive_path), *file_args])
assert result == 0
# Test extraction
extract_dir = temp_dir / "extracted_unicode"
result = main(["x", str(archive_path), "-o", str(extract_dir)])
assert result == 0
# Verify unicode content is preserved
for filename, original_content in files_to_create:
extracted_file = extract_dir / filename
assert extracted_file.exists()
extracted_content = extracted_file.read_text(encoding="utf-8")
assert extracted_content == original_content
def test_performance_edge_cases(self, temp_dir):
"""Test performance-related edge cases."""
# Create many small files
files = []
for i in range(50): # Create 50 small files
file_path = temp_dir / f"small_{i:03d}.txt"
file_path.write_text(f"Content of file {i}")
files.append(file_path)
archive_path = temp_dir / "many_files.tzst" # Create archive with many files
file_args = [str(f) for f in files]
result = main(["a", str(archive_path), *file_args])
assert result == 0
# Test listing (should handle many files efficiently)
result = main(["l", str(archive_path)])
assert result == 0
# Test extraction
extract_dir = temp_dir / "extracted_many"
result = main(["x", str(archive_path), "-o", str(extract_dir)])
assert result == 0
def test_error_recovery_edge_cases(self, temp_dir):
"""Test error recovery edge cases."""
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
# Create archive
result = main(["a", str(archive_path), str(test_file)])
assert result == 0
# Test with readonly archive
archive_path.chmod(0o444) # Make read-only
try:
# Should handle read-only archive gracefully
result = main(["l", str(archive_path)])
assert result == 0
finally:
# Restore write permissions for cleanup
archive_path.chmod(0o644)
def test_cross_platform_compatibility(self, temp_dir):
"""Test cross-platform compatibility features."""
# Create files with various characteristics
text_file = temp_dir / "text.txt"
text_file.write_text("Cross-platform text content\n")
binary_file = temp_dir / "binary.dat"
binary_file.write_bytes(bytes(range(256)))
archive_path = temp_dir / "cross_platform.tzst"
# Create archive
result = main(["a", str(archive_path), str(text_file), str(binary_file)])
assert result == 0
# Test with different compression levels
for level in [1, 11, 22]:
archive_path_level = temp_dir / f"cross_platform_level_{level}.tzst"
result = main(
["a", str(archive_path_level), str(text_file), "-c", str(level)]
)
assert result == 0
# Verify can be read back
result = main(["t", str(archive_path_level)])
assert result == 0
+463
View File
@@ -0,0 +1,463 @@
# filepath: e:\GitHub\tzst\tests\test_conflict_resolution_clean.py
"""Comprehensive tests for conflict resolution functionality."""
from unittest.mock import Mock, patch
from tzst.cli import _interactive_conflict_callback
from tzst.core import (
ConflictResolution,
ConflictResolutionState,
TzstArchive,
_get_unique_filename,
_handle_file_conflict,
create_archive,
extract_archive,
)
class TestConflictResolution:
"""Test conflict resolution enum and basic functionality."""
def test_conflict_resolution_enum_values(self):
"""Test that all ConflictResolution enum values exist."""
assert ConflictResolution.REPLACE.value == "replace"
assert ConflictResolution.SKIP.value == "skip"
assert ConflictResolution.REPLACE_ALL.value == "replace_all"
assert ConflictResolution.SKIP_ALL.value == "skip_all"
assert ConflictResolution.AUTO_RENAME.value == "auto_rename"
assert ConflictResolution.AUTO_RENAME_ALL.value == "auto_rename_all"
assert ConflictResolution.EXIT.value == "exit"
assert ConflictResolution.ASK.value == "ask"
class TestUniqueFilename:
"""Test unique filename generation."""
def test_get_unique_filename_basic(self, temp_dir):
"""Test basic unique filename generation."""
# Create a file
original_file = temp_dir / "test.txt"
original_file.write_text("original")
# Get unique name
unique_path = _get_unique_filename(original_file)
expected_path = temp_dir / "test_1.txt"
assert unique_path == expected_path
assert not unique_path.exists()
def test_get_unique_filename_multiple_conflicts(self, temp_dir):
"""Test unique filename generation with multiple conflicts."""
# Create multiple files
original_file = temp_dir / "test.txt"
conflict1 = temp_dir / "test_1.txt"
conflict2 = temp_dir / "test_2.txt"
original_file.write_text("original")
conflict1.write_text("conflict1")
conflict2.write_text("conflict2")
# Get unique name
unique_path = _get_unique_filename(original_file)
expected_path = temp_dir / "test_3.txt"
assert unique_path == expected_path
assert not unique_path.exists()
def test_get_unique_filename_no_extension(self, temp_dir):
"""Test unique filename generation for files without extension."""
# Create a file without extension
original_file = temp_dir / "README"
original_file.write_text("readme content")
# Get unique name
unique_path = _get_unique_filename(original_file)
expected_path = temp_dir / "README_1"
assert unique_path == expected_path
assert not unique_path.exists()
def test_get_unique_filename_empty_stem(self, temp_dir):
"""Test unique filename generation for files with empty stem."""
# Create a file with empty stem (just extension)
original_file = temp_dir / ".gitignore"
original_file.write_text("git ignore")
# Get unique name
unique_path = _get_unique_filename(original_file)
expected_path = temp_dir / ".gitignore_1"
assert unique_path == expected_path
assert not unique_path.exists()
class TestHandleFileConflict:
"""Test file conflict handling function."""
def test_handle_file_conflict_replace(self, temp_dir):
"""Test REPLACE conflict resolution."""
target_path = temp_dir / "existing.txt"
target_path.write_text("existing")
resolution, final_path = _handle_file_conflict(
target_path, ConflictResolution.REPLACE, None
)
assert resolution == ConflictResolution.REPLACE
assert final_path == target_path
def test_handle_file_conflict_skip(self, temp_dir):
"""Test SKIP conflict resolution."""
target_path = temp_dir / "existing.txt"
target_path.write_text("existing")
resolution, final_path = _handle_file_conflict(
target_path, ConflictResolution.SKIP, None
)
assert resolution == ConflictResolution.SKIP
assert final_path is None
def test_handle_file_conflict_replace_all(self, temp_dir):
"""Test REPLACE_ALL conflict resolution."""
target_path = temp_dir / "existing.txt"
target_path.write_text("existing")
resolution, final_path = _handle_file_conflict(
target_path, ConflictResolution.REPLACE_ALL, None
)
assert resolution == ConflictResolution.REPLACE_ALL
assert final_path == target_path
def test_handle_file_conflict_skip_all(self, temp_dir):
"""Test SKIP_ALL conflict resolution."""
target_path = temp_dir / "existing.txt"
target_path.write_text("existing")
resolution, final_path = _handle_file_conflict(
target_path, ConflictResolution.SKIP_ALL, None
)
assert resolution == ConflictResolution.SKIP_ALL
assert final_path is None
def test_handle_file_conflict_auto_rename(self, temp_dir):
"""Test AUTO_RENAME conflict resolution."""
target_path = temp_dir / "existing.txt"
target_path.write_text("existing")
resolution, final_path = _handle_file_conflict(
target_path, ConflictResolution.AUTO_RENAME, None
)
assert resolution == ConflictResolution.AUTO_RENAME
assert final_path == temp_dir / "existing_1.txt"
assert not final_path.exists()
def test_handle_file_conflict_auto_rename_all(self, temp_dir):
"""Test AUTO_RENAME_ALL conflict resolution."""
target_path = temp_dir / "existing.txt"
target_path.write_text("existing")
resolution, final_path = _handle_file_conflict(
target_path, ConflictResolution.AUTO_RENAME_ALL, None
)
assert resolution == ConflictResolution.AUTO_RENAME_ALL
assert final_path == temp_dir / "existing_1.txt"
assert not final_path.exists()
def test_handle_file_conflict_exit(self, temp_dir):
"""Test EXIT conflict resolution."""
target_path = temp_dir / "existing.txt"
target_path.write_text("existing")
resolution, final_path = _handle_file_conflict(
target_path, ConflictResolution.EXIT, None
)
assert resolution == ConflictResolution.EXIT
assert final_path is None
def test_handle_file_conflict_ask_with_callback(self, temp_dir):
"""Test ASK conflict resolution with callback."""
target_path = temp_dir / "existing.txt"
target_path.write_text("existing")
mock_callback = Mock(return_value=ConflictResolution.REPLACE)
resolution, final_path = _handle_file_conflict(
target_path, ConflictResolution.ASK, mock_callback
)
assert resolution == ConflictResolution.REPLACE
assert final_path == target_path
mock_callback.assert_called_once_with(target_path)
def test_handle_file_conflict_ask_no_callback(self, temp_dir):
"""Test ASK conflict resolution without callback defaults to REPLACE."""
target_path = temp_dir / "existing.txt"
target_path.write_text("existing")
resolution, final_path = _handle_file_conflict(
target_path, ConflictResolution.ASK, None
)
assert resolution == ConflictResolution.REPLACE
assert final_path == target_path
def test_handle_file_conflict_string_resolution_valid(self, temp_dir):
"""Test string-based conflict resolution."""
target_path = temp_dir / "existing.txt"
target_path.write_text("existing")
resolution, final_path = _handle_file_conflict(target_path, "skip", None)
assert resolution == ConflictResolution.SKIP
assert final_path is None
def test_handle_file_conflict_string_resolution_invalid(self, temp_dir):
"""Test invalid string conflict resolution defaults to REPLACE."""
target_path = temp_dir / "existing.txt"
target_path.write_text("existing")
resolution, final_path = _handle_file_conflict(
target_path, "invalid_resolution", None
)
assert resolution == ConflictResolution.REPLACE
assert final_path == target_path
def test_handle_file_conflict_unknown_resolution(self, temp_dir):
"""Test unknown conflict resolution defaults to REPLACE."""
target_path = temp_dir / "existing.txt"
target_path.write_text("existing")
# Pass something that's not a valid enum value
resolution, final_path = _handle_file_conflict(
target_path, "completely_unknown", None
)
assert resolution == ConflictResolution.REPLACE
assert final_path == target_path
class TestConflictResolutionState:
"""Test conflict resolution state management."""
def test_initial_state(self):
"""Test initial state with different resolutions."""
state1 = ConflictResolutionState(ConflictResolution.REPLACE)
assert state1.current_resolution == ConflictResolution.REPLACE
assert state1.should_continue() is True
state2 = ConflictResolutionState(ConflictResolution.EXIT)
assert state2.current_resolution == ConflictResolution.EXIT
assert state2.should_continue() is False
def test_update_resolution_replace_all(self):
"""Test updating to REPLACE_ALL."""
state = ConflictResolutionState(ConflictResolution.ASK)
state.update_resolution(ConflictResolution.REPLACE_ALL)
assert state.current_resolution == ConflictResolution.REPLACE_ALL
assert state.should_continue() is True
def test_update_resolution_skip_all(self):
"""Test updating to SKIP_ALL."""
state = ConflictResolutionState(ConflictResolution.ASK)
state.update_resolution(ConflictResolution.SKIP_ALL)
assert state.current_resolution == ConflictResolution.SKIP_ALL
assert state.should_continue() is True
def test_update_resolution_auto_rename_all(self):
"""Test updating to AUTO_RENAME_ALL."""
state = ConflictResolutionState(ConflictResolution.ASK)
state.update_resolution(ConflictResolution.AUTO_RENAME_ALL)
assert state.current_resolution == ConflictResolution.AUTO_RENAME_ALL
assert state.should_continue() is True
def test_update_resolution_exit(self):
"""Test updating to EXIT."""
state = ConflictResolutionState(ConflictResolution.ASK)
state.update_resolution(ConflictResolution.EXIT)
assert state.current_resolution == ConflictResolution.EXIT
assert state.should_continue() is False
def test_update_resolution_normal(self):
"""Test updating to normal resolution doesn't change state."""
state = ConflictResolutionState(ConflictResolution.ASK)
state.update_resolution(ConflictResolution.REPLACE)
assert state.current_resolution == ConflictResolution.ASK
assert state.should_continue() is True
class TestExtractArchiveConflictResolution:
"""Test extract_archive with conflict resolution."""
def test_extract_with_replace_conflict_resolution(self, temp_dir):
"""Test extraction with REPLACE conflict resolution."""
# Create archive
archive_path = temp_dir / "test.tzst"
source_file = temp_dir / "source.txt"
source_file.write_text("archive content")
create_archive(archive_path, [source_file])
# Create extract directory with conflicting file
extract_dir = temp_dir / "extract"
extract_dir.mkdir()
conflict_file = extract_dir / "source.txt"
conflict_file.write_text("existing content")
# Extract with REPLACE resolution
extract_archive(
archive_path, extract_dir, conflict_resolution=ConflictResolution.REPLACE
)
# Verify file was replaced
assert conflict_file.read_text() == "archive content"
def test_extract_with_skip_conflict_resolution(self, temp_dir):
"""Test extraction with SKIP conflict resolution."""
# Create archive
archive_path = temp_dir / "test.tzst"
source_file = temp_dir / "source.txt"
source_file.write_text("archive content")
create_archive(archive_path, [source_file])
# Create extract directory with conflicting file
extract_dir = temp_dir / "extract"
extract_dir.mkdir()
conflict_file = extract_dir / "source.txt"
conflict_file.write_text("existing content")
# Extract with SKIP resolution
extract_archive(
archive_path, extract_dir, conflict_resolution=ConflictResolution.SKIP
)
# Verify file was not replaced
assert conflict_file.read_text() == "existing content"
def test_extract_with_interactive_callback(self, temp_dir):
"""Test extraction with interactive callback."""
# Create archive
archive_path = temp_dir / "test.tzst"
source_file = temp_dir / "source.txt"
source_file.write_text("archive content")
create_archive(archive_path, [source_file])
# Create extract directory with conflicting file
extract_dir = temp_dir / "extract"
extract_dir.mkdir()
conflict_file = extract_dir / "source.txt"
conflict_file.write_text("existing content")
mock_callback = Mock(return_value=ConflictResolution.REPLACE)
# Extract with interactive callback
extract_archive(
archive_path,
extract_dir,
conflict_resolution=ConflictResolution.ASK,
interactive_callback=mock_callback,
)
# Verify callback was called and file was replaced
mock_callback.assert_called_once()
assert conflict_file.read_text() == "archive content"
class TestTzstArchiveConflictResolution:
"""Test extract_archive function with conflict resolution (corrected)."""
def test_extract_archive_with_conflict_resolution(self, temp_dir):
"""Test extract_archive function with conflict resolution parameter."""
# Create archive
source_file = temp_dir / "source.txt"
source_file.write_text("archive content")
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
archive.add(str(source_file), arcname="source.txt")
# Create conflicting file in extract directory
extract_dir = temp_dir / "extract"
extract_dir.mkdir()
conflict_file = extract_dir / "source.txt"
conflict_file.write_text("existing content")
# Extract with REPLACE resolution using extract_archive function
extract_archive(
archive_path, extract_dir, conflict_resolution=ConflictResolution.REPLACE
)
# Should have replaced the file
assert conflict_file.read_text() == "archive content"
class TestInteractiveConflictCallback:
"""Test interactive conflict callback functionality."""
@patch("builtins.input")
def test_interactive_callback_replace(self, mock_input, temp_dir):
"""Test interactive callback with replace choice."""
mock_input.return_value = "r"
file_path = temp_dir / "test.txt"
result = _interactive_conflict_callback(file_path)
assert result == ConflictResolution.REPLACE
@patch("builtins.input")
def test_interactive_callback_skip(self, mock_input, temp_dir):
"""Test interactive callback with skip choice."""
mock_input.return_value = "n"
file_path = temp_dir / "test.txt"
result = _interactive_conflict_callback(file_path)
assert result == ConflictResolution.SKIP
@patch("builtins.input")
def test_interactive_callback_exit(self, mock_input, temp_dir):
"""Test interactive callback with exit choice."""
mock_input.return_value = "x"
file_path = temp_dir / "test.txt"
result = _interactive_conflict_callback(file_path)
assert result == ConflictResolution.EXIT
@patch("builtins.input")
def test_interactive_callback_invalid_then_valid(self, mock_input, temp_dir):
"""Test interactive callback with invalid then valid choice."""
mock_input.side_effect = ["invalid", "r"]
file_path = temp_dir / "test.txt"
result = _interactive_conflict_callback(file_path)
assert result == ConflictResolution.REPLACE
assert mock_input.call_count == 2
@patch("builtins.input")
def test_interactive_callback_keyboard_interrupt(self, mock_input, temp_dir):
"""Test interactive callback with KeyboardInterrupt."""
mock_input.side_effect = KeyboardInterrupt()
file_path = temp_dir / "test.txt"
result = _interactive_conflict_callback(file_path)
assert result == ConflictResolution.EXIT
@patch("builtins.input")
def test_interactive_callback_eof_error(self, mock_input, temp_dir):
"""Test interactive callback with EOFError."""
mock_input.side_effect = EOFError()
file_path = temp_dir / "test.txt"
result = _interactive_conflict_callback(file_path)
assert result == ConflictResolution.EXIT
-216
View File
@@ -1,216 +0,0 @@
"""Tests to exercise conftest.py fixtures and improve coverage."""
from tzst import create_archive, extract_archive, list_archive
from tzst.core import TzstArchive
class TestConftestFixtures:
"""Test all conftest.py fixtures to improve coverage."""
def test_comprehensive_test_files_fixture(self, comprehensive_test_files, temp_dir):
"""Test the comprehensive_test_files fixture."""
# Ensure we have the expected file types
assert len(comprehensive_test_files) >= 9
file_names = [f.name for f in comprehensive_test_files]
# Check for specific files that should be created
assert "empty_file.txt" in file_names
assert "whitespace_only.txt" in file_names
assert "newlines_only.txt" in file_names
assert "null_bytes.bin" in file_names
assert "large_file.txt" in file_names
assert "binary_data.bin" in file_names
assert "file with spaces.txt" in file_names
assert "unicode_content.txt" in file_names
assert "deepest_file.txt" in file_names
# Create archive with these comprehensive test files
archive_path = temp_dir / "comprehensive.tzst"
file_paths = [str(f) for f in comprehensive_test_files if f.is_file()]
create_archive(archive_path, file_paths)
# Verify archive was created and contains expected files
assert archive_path.exists()
contents = list_archive(archive_path)
assert len(contents) >= 9
def test_platform_specific_files_fixture(self, platform_specific_files, temp_dir):
"""Test the platform_specific_files fixture."""
# This fixture may return empty list on Windows, non-empty on Unix
# Just ensure it doesn't crash and returns a list
assert isinstance(platform_specific_files, list)
if platform_specific_files:
# If we have platform-specific files, create an archive with them
archive_path = temp_dir / "platform_specific.tzst"
file_paths = [str(f) for f in platform_specific_files if f.is_file()]
if file_paths:
create_archive(archive_path, file_paths)
assert archive_path.exists()
def test_compression_test_files_fixture(self, compression_test_files, temp_dir):
"""Test the compression_test_files fixture."""
assert len(compression_test_files) == 2
file_names = [f.name for f in compression_test_files]
assert "highly_compressible.txt" in file_names
assert "poorly_compressible.bin" in file_names
# Test different compression levels with these files
for level in [1, 11, 22]:
archive_path = temp_dir / f"compression_level_{level}.tzst"
with TzstArchive(
archive_path, mode="w", compression_level=level
) as archive:
for file_path in compression_test_files:
if file_path.is_file():
archive.add(str(file_path), arcname=file_path.name)
assert archive_path.exists()
contents = list_archive(archive_path)
assert len(contents) == 2
def test_combined_fixtures_workflow(
self, comprehensive_test_files, compression_test_files, temp_dir
):
"""Test using multiple fixtures together."""
all_files = comprehensive_test_files + compression_test_files
file_paths = [str(f) for f in all_files if f.is_file()]
# Create archive with all files
archive_path = temp_dir / "combined.tzst"
create_archive(archive_path, file_paths)
# Extract and verify
extract_dir = temp_dir / "extracted"
extract_archive(archive_path, extract_dir)
# Verify archive was created and extracted directory exists
assert archive_path.exists()
assert extract_dir.exists()
# Count files instead of checking exact names (due to nested structure)
extracted_files = list(extract_dir.rglob("*"))
extracted_file_count = len([f for f in extracted_files if f.is_file()])
original_file_count = len([f for f in all_files if f.is_file()])
# Should have extracted at least some files
assert extracted_file_count > 0
assert extracted_file_count <= original_file_count
def test_unicode_content_file_handling(self, comprehensive_test_files, temp_dir):
"""Test handling of unicode content specifically."""
unicode_files = [f for f in comprehensive_test_files if "unicode" in f.name]
assert len(unicode_files) >= 1
unicode_file = unicode_files[0]
content = unicode_file.read_text(encoding="utf-8")
assert "世界" in content
assert "🌍" in content
# Create archive and verify unicode handling
archive_path = temp_dir / "unicode.tzst"
create_archive(archive_path, [str(unicode_file)])
# Extract and verify content is preserved
extract_dir = temp_dir / "extracted_unicode"
extract_archive(archive_path, extract_dir)
extracted_file = extract_dir / unicode_file.name
extracted_content = extracted_file.read_text(encoding="utf-8")
assert extracted_content == content
def test_special_character_filenames(self, comprehensive_test_files, temp_dir):
"""Test files with special characters in names."""
special_files = [f for f in comprehensive_test_files if " " in f.name]
assert len(special_files) >= 1
archive_path = temp_dir / "special_chars.tzst"
file_paths = [str(f) for f in special_files if f.is_file()]
create_archive(archive_path, file_paths)
contents = list_archive(archive_path)
assert any(" " in item["name"] for item in contents)
def test_deeply_nested_structure(self, comprehensive_test_files, temp_dir):
"""Test deeply nested directory structure."""
nested_files = [f for f in comprehensive_test_files if "deepest" in f.name]
assert len(nested_files) >= 1
nested_file = nested_files[0]
assert "nested" in str(nested_file.parent)
# Create archive maintaining directory structure
archive_path = temp_dir / "nested.tzst"
with TzstArchive(archive_path, mode="w") as archive:
archive.add(
str(nested_file), arcname=str(nested_file.relative_to(temp_dir))
)
contents = list_archive(archive_path)
assert any("nested" in item["name"] for item in contents)
def test_empty_and_whitespace_files(self, comprehensive_test_files, temp_dir):
"""Test empty and whitespace-only files."""
empty_files = [
f
for f in comprehensive_test_files
if "empty" in f.name or "whitespace" in f.name or "newlines" in f.name
]
assert len(empty_files) >= 3
archive_path = temp_dir / "empty_whitespace.tzst"
file_paths = [str(f) for f in empty_files if f.is_file()]
create_archive(archive_path, file_paths)
# Extract and verify these special cases are handled
extract_dir = temp_dir / "extracted_empty"
extract_archive(archive_path, extract_dir)
for file_path in empty_files:
if file_path.is_file():
extracted_file = extract_dir / file_path.name
assert extracted_file.exists()
def test_binary_data_handling(self, comprehensive_test_files, temp_dir):
"""Test binary files with null bytes and binary data."""
binary_files = [f for f in comprehensive_test_files if f.suffix == ".bin"]
assert len(binary_files) >= 2
archive_path = temp_dir / "binary.tzst"
file_paths = [str(f) for f in binary_files if f.is_file()]
create_archive(archive_path, file_paths)
# Extract and verify binary content is preserved
extract_dir = temp_dir / "extracted_binary"
extract_archive(archive_path, extract_dir)
for file_path in binary_files:
if file_path.is_file():
extracted_file = extract_dir / file_path.name
assert extracted_file.exists()
# Verify binary content is identical
original_content = file_path.read_bytes()
extracted_content = extracted_file.read_bytes()
assert original_content == extracted_content
def test_large_file_handling(self, comprehensive_test_files, temp_dir):
"""Test large file handling."""
large_files = [f for f in comprehensive_test_files if "large" in f.name]
assert len(large_files) >= 1
large_file = large_files[0]
# Verify it's actually large
assert large_file.stat().st_size > 100000 # Should be > 100KB
archive_path = temp_dir / "large.tzst"
create_archive(archive_path, [str(large_file)])
# Test with streaming mode
archive_path_streaming = temp_dir / "large_streaming.tzst"
with TzstArchive(archive_path_streaming, mode="w", streaming=True) as archive:
archive.add(str(large_file), arcname=large_file.name)
# Both archives should exist
assert archive_path.exists()
assert archive_path_streaming.exists()
+253
View File
@@ -0,0 +1,253 @@
"""Tests for edge cases and error conditions in core.py to improve coverage.
This test file targets the specific missing lines identified in the coverage report,
focusing on error handling, edge cases, and less common code paths.
"""
import tarfile
from unittest.mock import Mock, patch
import pytest
from tzst.core import (
ConflictResolution,
TzstArchive,
_handle_file_conflict,
create_archive,
extract_archive,
)
class TestConflictResolutionEdgeCases:
"""Test edge cases in conflict resolution handling."""
def test_handle_file_conflict_invalid_string_resolution(self, temp_dir):
"""Test _handle_file_conflict with invalid string resolution."""
target_path = temp_dir / "existing.txt"
target_path.write_text("existing content")
# Test with invalid string - should fallback to ASK
result_action, result_path = _handle_file_conflict(
target_path, "invalid_resolution", None
) # Should fallback to REPLACE when no interactive callback
assert result_action == ConflictResolution.REPLACE
assert result_path == target_path
def test_handle_file_conflict_ask_with_callback(self, temp_dir):
"""Test ASK resolution with interactive callback."""
target_path = temp_dir / "existing.txt"
target_path.write_text("existing content")
# Mock interactive callback that returns REPLACE
mock_callback = Mock(return_value=ConflictResolution.REPLACE)
result_action, result_path = _handle_file_conflict(
target_path, ConflictResolution.ASK, mock_callback
)
assert result_action == ConflictResolution.REPLACE
assert result_path == target_path
mock_callback.assert_called_once_with(target_path)
def test_handle_file_conflict_ask_without_callback(self, temp_dir):
"""Test ASK resolution without interactive callback."""
target_path = temp_dir / "existing.txt"
target_path.write_text("existing content")
result_action, result_path = _handle_file_conflict(
target_path, ConflictResolution.ASK, None
) # Should default to REPLACE when no callback available
assert result_action == ConflictResolution.REPLACE
assert result_path == target_path
def test_handle_file_conflict_unknown_resolution(self, temp_dir):
"""Test handling of unknown resolution types."""
target_path = temp_dir / "existing.txt"
target_path.write_text("existing content")
# Create a mock enum value that's not handled
unknown_resolution = Mock()
unknown_resolution.name = "UNKNOWN"
result_action, result_path = _handle_file_conflict(
target_path, unknown_resolution, None
)
# Should default to REPLACE for unknown resolutions
assert result_action == ConflictResolution.REPLACE
assert result_path == target_path
class TestArchiveErrorHandling:
"""Test error handling in archive operations."""
def test_archive_streaming_extraction_error(self, temp_dir):
"""Test extraction error in streaming mode."""
# Create a simple archive first
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
create_archive(archive_path, [test_file])
# Mock tarfile to raise StreamError
with patch("tarfile.open") as mock_open:
mock_tarfile = Mock()
mock_open.return_value.__enter__.return_value = mock_tarfile
mock_tarfile.extractall.side_effect = tarfile.StreamError(
"seeking not allowed"
)
archive = TzstArchive(archive_path, "r", streaming=True)
archive._tarfile = mock_tarfile
archive.streaming = True
with pytest.raises(
RuntimeError, match="Extraction failed in streaming mode"
):
archive.extractall(temp_dir / "extract")
def test_getmembers_archive_not_open(self):
"""Test getmembers when archive is not open."""
archive = TzstArchive("dummy.tzst", "r")
archive._tarfile = None
with pytest.raises(RuntimeError, match="Archive not open"):
archive.getmembers()
def test_getmembers_wrong_mode(self, temp_dir):
"""Test getmembers when archive is not in read mode."""
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, "w") as archive:
with pytest.raises(RuntimeError, match="Archive not open for reading"):
archive.getmembers()
def test_getnames_archive_not_open(self):
"""Test getnames when archive is not open."""
archive = TzstArchive("dummy.tzst", "r")
archive._tarfile = None
with pytest.raises(RuntimeError, match="Archive not open"):
archive.getnames()
def test_getnames_wrong_mode(self, temp_dir):
"""Test getnames when archive is not in read mode."""
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, "w") as archive:
with pytest.raises(RuntimeError, match="Archive not open for reading"):
archive.getnames()
class TestCreateArchiveErrorHandling:
"""Test error handling in archive creation."""
def test_create_archive_cleanup_on_error(self, temp_dir):
"""Test archive creation cleans up on error."""
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
# Test that errors during archive creation are properly handled
# Simulate error by trying to create archive with invalid compression level
with pytest.raises(ValueError, match="Invalid compression level"):
create_archive(archive_path, [test_file], compression_level=50)
# Archive should not be created when error occurs
assert not archive_path.exists()
def test_create_archive_common_path_no_common_parent(self, temp_dir):
"""Test create_archive when files have no common parent path."""
file1 = temp_dir / "file1.txt"
file1.write_text("content1")
with patch("os.path.commonpath", side_effect=ValueError("no common path")):
archive_path = temp_dir / "test.tzst"
# Should use parent of first file as fallback
create_archive(archive_path, [file1])
assert archive_path.exists()
class TestExtractArchiveEdgeCases:
"""Test edge cases in archive extraction."""
def test_extract_archive_exit_resolution(self, temp_dir):
"""Test extraction with EXIT conflict resolution."""
# Create archive with multiple files
file1 = temp_dir / "file1.txt"
file1.write_text("content1")
file2 = temp_dir / "file2.txt"
file2.write_text("content2")
archive_path = temp_dir / "test.tzst"
create_archive(archive_path, [file1, file2])
extract_dir = temp_dir / "extract"
extract_dir.mkdir()
# Create a conflicting file
conflict_file = extract_dir / "file1.txt"
conflict_file.write_text("existing content")
# Extract with EXIT resolution
extract_archive(
archive_path, extract_dir, conflict_resolution=ConflictResolution.EXIT
)
# Should stop on first conflict
assert conflict_file.read_text() == "existing content"
assert not (extract_dir / "file2.txt").exists()
def test_extract_archive_members_with_state_break(self, temp_dir):
"""Test extraction of specific members with state break."""
# Create archive with multiple files
file1 = temp_dir / "file1.txt"
file1.write_text("content1")
file2 = temp_dir / "file2.txt"
file2.write_text("content2")
archive_path = temp_dir / "test.tzst"
create_archive(archive_path, [file1, file2])
extract_dir = temp_dir / "extract"
extract_dir.mkdir()
# Mock ConflictResolutionState to return False for should_continue
with patch("tzst.core.ConflictResolutionState") as mock_state_class:
mock_state = Mock()
mock_state.should_continue.return_value = False
mock_state.apply_to_all = False
mock_state_class.return_value = mock_state
extract_archive(
archive_path, extract_dir, members=["file1.txt", "file2.txt"]
)
# Should break early due to state.should_continue()
assert mock_state.should_continue.called
def test_extract_archive_with_filter_parameter(self, temp_dir):
"""Test extraction using filter parameter."""
# Create archive with a file
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
create_archive(archive_path, [test_file])
extract_dir = temp_dir / "extract"
extract_dir.mkdir()
# Mock filter function
def mock_filter(member, path):
return member
# Extract with filter (tests the filter parameter branch)
extract_archive(archive_path, extract_dir, filter=mock_filter)
extracted_file = extract_dir / "test.txt"
assert extracted_file.exists()
assert extracted_file.read_text() == "test content"
-395
View File
@@ -1,395 +0,0 @@
"""Tests to cover missing lines in core.py for improved coverage."""
import tarfile
from unittest.mock import MagicMock, patch
import pytest
from tzst.core import TzstArchive
from tzst.exceptions import TzstArchiveError, TzstDecompressionError
class TestCoreMissingLines:
"""Test specific missing lines in core.py."""
def test_append_mode_error_handling(self, temp_dir):
"""Test append mode error handling (lines 126-137)."""
archive_path = temp_dir / "test.tzst"
# Test append mode raises NotImplementedError
with pytest.raises(
NotImplementedError, match="Append mode is not currently supported"
):
TzstArchive(archive_path, mode="a")
def test_invalid_mode_error_after_open(self, temp_dir):
"""Test invalid mode error in __enter__ method (line 137)."""
archive_path = temp_dir / "test.tzst"
# Create archive instance with invalid mode after validation passes
archive = TzstArchive.__new__(TzstArchive)
archive.filename = archive_path
archive.mode = "invalid" # Set invalid mode after construction
archive.compression_level = 3
archive.streaming = False
archive._tarfile = None
archive._fileobj = None
archive._compressed_stream = None
with pytest.raises(TzstArchiveError, match="Failed to open archive"):
archive.__enter__()
def test_zstd_error_handling_in_open(self, temp_dir):
"""Test zstd error handling during archive opening (lines 133-137)."""
archive_path = temp_dir / "test.tzst"
# Create a file that will cause zstd decompression error
archive_path.write_bytes(b"invalid zstd data")
# Try to open as read mode - should raise TzstDecompressionError
with pytest.raises(TzstDecompressionError, match="Failed to open archive"):
with TzstArchive(archive_path, mode="r"):
pass
def test_generic_error_handling_in_open(self, temp_dir):
"""Test generic error handling during archive opening."""
archive_path = temp_dir / "test.tzst"
# Mock to raise a generic exception (not zstd-related)
with patch("builtins.open", side_effect=PermissionError("Permission denied")):
with pytest.raises(TzstArchiveError, match="Failed to open archive"):
with TzstArchive(archive_path, mode="r"):
pass
def test_close_error_handling(self, temp_dir):
"""Test error handling in close method (lines 146-157)."""
archive_path = temp_dir / "test.tzst"
# Create archive and manually set objects that will raise on close
with TzstArchive(archive_path, mode="w") as archive:
pass
# Now manually create problematic objects
archive = TzstArchive.__new__(TzstArchive)
archive._tarfile = MagicMock()
archive._tarfile.close.side_effect = Exception("Close error")
archive._compressed_stream = MagicMock()
archive._compressed_stream.close.side_effect = Exception("Close error")
archive._fileobj = MagicMock()
archive._fileobj.close.side_effect = Exception("Close error")
# close() should handle exceptions gracefully
archive.close() # Should not raise
assert archive._tarfile is None
assert archive._compressed_stream is None
assert archive._fileobj is None
def test_archive_not_open_for_reading_errors(self, temp_dir):
"""Test RuntimeError for operations on archives not open for reading (lines 186, 188, 192)."""
archive_path = temp_dir / "test.tzst"
# Create archive in write mode
with TzstArchive(archive_path, mode="w") as archive:
# Test getmembers() on write mode
with pytest.raises(RuntimeError, match="Archive not open for reading"):
archive.getmembers()
# Test getnames() on write mode
with pytest.raises(RuntimeError, match="Archive not open for reading"):
archive.getnames()
# Test extractfile() on write mode
with pytest.raises(RuntimeError, match="Archive not open for reading"):
archive.extractfile("test")
def test_streaming_member_extraction_error(self, temp_dir):
"""Test streaming mode member extraction error (lines 242, 249-254)."""
# Create a test archive first
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
archive.add(str(test_file), arcname="test.txt")
# Try to extract specific member in streaming mode
with TzstArchive(archive_path, mode="r", streaming=True) as archive:
members = archive.getmembers()
member = members[0]
extract_dir = temp_dir / "extract"
extract_dir.mkdir() # Should raise RuntimeError for specific member extraction in streaming mode
with pytest.raises(
RuntimeError,
match="Extracting specific members is not supported in streaming mode",
):
archive.extract(member=member.name, path=extract_dir)
def test_streaming_extraction_failure_handling(self, temp_dir):
"""Test streaming extraction failure handling (lines 263-272)."""
# Create archive first
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
archive.add(
str(test_file), arcname="test.txt"
) # Mock tarfile to raise StreamError
with TzstArchive(archive_path, mode="r", streaming=True) as archive:
extract_dir = temp_dir / "extract"
extract_dir.mkdir()
# Mock extractall to raise StreamError with streaming-related message
with patch.object(
archive._tarfile,
"extractall",
side_effect=tarfile.StreamError("seeking not supported"),
):
with pytest.raises(
RuntimeError, match="Extraction failed in streaming mode"
):
archive.extract(path=extract_dir)
def test_extractfile_not_open_error(self, temp_dir):
"""Test extractfile when archive is not open (line 307)."""
archive_path = temp_dir / "test.tzst"
# Create closed archive
archive = TzstArchive(archive_path, mode="r")
# Don't open it
with pytest.raises(RuntimeError, match="Archive not open"):
archive.extractfile("test")
def test_extractfile_write_mode_error(self, temp_dir):
"""Test extractfile in write mode (already covered but ensuring line coverage)."""
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
with pytest.raises(RuntimeError, match="Archive not open for reading"):
archive.extractfile("test")
def test_add_method_not_open_error(self, temp_dir):
"""Test add method when archive is not open (line 325, 327)."""
archive_path = temp_dir / "test.tzst"
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
# Create archive but don't open it
archive = TzstArchive(archive_path, mode="w")
with pytest.raises(RuntimeError, match="Archive not open"):
archive.add(str(test_file))
def test_add_method_read_mode_error(self, temp_dir):
"""Test add method in read mode (line 327)."""
# Create archive first
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
archive.add(str(test_file), arcname="test.txt")
# Try to add to archive in read mode
with TzstArchive(archive_path, mode="r") as archive:
with pytest.raises(RuntimeError, match="Archive not open for writing"):
archive.add(str(test_file))
def test_file_not_found_in_add(self, temp_dir):
"""Test file not found error in add method (line 373)."""
archive_path = temp_dir / "test.tzst"
missing_file = temp_dir / "missing.txt"
with TzstArchive(archive_path, mode="w") as archive:
with pytest.raises(FileNotFoundError):
archive.add(str(missing_file))
def test_add_method_generic_error_handling(self, temp_dir):
"""Test generic error handling in add method (line 375)."""
archive_path = temp_dir / "test.tzst"
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
with TzstArchive(archive_path, mode="w") as archive:
# Mock add to raise generic exception
with patch.object(
archive._tarfile,
"add",
side_effect=PermissionError("Permission denied"),
):
with pytest.raises(TzstArchiveError, match="Failed to add"):
archive.add(str(test_file))
def test_test_method_not_open_error(self, temp_dir):
"""Test test method when archive is not open (line 390-391)."""
archive_path = temp_dir / "test.tzst"
# Create archive but don't open it
archive = TzstArchive(archive_path, mode="r")
with pytest.raises(RuntimeError, match="Archive not open"):
archive.test()
def test_test_method_write_mode_error(self, temp_dir):
"""Test test method in write mode (line 391)."""
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
with pytest.raises(RuntimeError, match="Archive not open for reading"):
archive.test()
def test_test_method_streaming_mode_info(self, temp_dir):
"""Test test method streaming mode information (line 427)."""
# Create archive first
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
archive.add(str(test_file), arcname="test.txt")
# Test in streaming mode - should provide different behavior info
with TzstArchive(archive_path, mode="r", streaming=True) as archive:
# This should work but may have streaming-specific behavior
result = archive.test()
assert isinstance(result, bool)
def test_list_method_not_open_error(self, temp_dir):
"""Test list method when archive is not open (line 454-455)."""
archive_path = temp_dir / "test.tzst"
# Create archive but don't open it
archive = TzstArchive(archive_path, mode="r")
with pytest.raises(RuntimeError, match="Archive not open"):
list(archive.list())
def test_list_method_write_mode_error(self, temp_dir):
"""Test list method in write mode (line 455)."""
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
with pytest.raises(RuntimeError, match="Archive not open for reading"):
list(archive.list())
def test_context_manager_exception_handling(self, temp_dir):
"""Test context manager exception handling (lines 502-504)."""
archive_path = temp_dir / "test.tzst"
# Store references for cleanup
fileobj = None
compressed_stream = None
tarfile_obj = None
# Test that close exceptions are suppressed during context manager exit
with patch("tzst.core.TzstArchive.close", side_effect=Exception("Close error")):
try:
with TzstArchive(archive_path, mode="w") as archive:
# Store references to underlying objects for manual cleanup
fileobj = archive._fileobj
compressed_stream = archive._compressed_stream
tarfile_obj = archive._tarfile
raise ValueError("Test exception")
except ValueError:
pass # Expected - the original exception should not be masked
finally:
# Manually clean up since mocked close() failed
try:
if tarfile_obj:
tarfile_obj.close()
except Exception:
pass
try:
if compressed_stream:
compressed_stream.close()
except Exception:
pass
try:
if fileobj:
fileobj.close()
except Exception:
pass
# Ensure the file is removed to prevent permission errors
try:
if archive_path.exists():
archive_path.unlink()
except (PermissionError, OSError):
pass
# The close exception should be suppressed by __exit__
def test_streaming_mode_directory_creation_error(self, temp_dir):
"""Test directory creation error in streaming mode (line 521)."""
# Create archive first
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
archive.add(str(test_file), arcname="test.txt")
with TzstArchive(archive_path, mode="r", streaming=True) as archive:
# Mock path creation to fail
extract_dir = temp_dir / "extract"
with patch("pathlib.Path.mkdir", side_effect=OSError("Permission denied")):
with pytest.raises(OSError):
archive.extractall(path=extract_dir)
def test_list_verbose_mode_edge_cases(self, temp_dir):
"""Test list method verbose mode edge cases (lines 573, 588-589)."""
# Create archive with special files
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
# Create a directory
test_dir = temp_dir / "test_dir"
test_dir.mkdir()
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
archive.add(str(test_file), arcname="test.txt")
archive.add(str(test_dir), arcname="test_dir")
with TzstArchive(archive_path, mode="r") as archive:
# Test verbose listing
items = list(archive.list(verbose=True))
assert len(items) >= 2
# Should have both file and directory entries
file_items = [item for item in items if item.get("is_file", False)]
dir_items = [item for item in items if item.get("is_dir", False)]
assert len(file_items) >= 1
assert len(dir_items) >= 1
def test_extractall_with_members_parameter(self, temp_dir):
"""Test extractall with members parameter for selective extraction."""
# Create archive with multiple files
test_file1 = temp_dir / "test1.txt"
test_file1.write_text("content1")
test_file2 = temp_dir / "test2.txt"
test_file2.write_text("content2")
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
archive.add(str(test_file1), arcname="test1.txt")
archive.add(str(test_file2), arcname="test2.txt")
# Extract only specific members
with TzstArchive(archive_path, mode="r") as archive:
members = archive.getmembers()
first_member = members[0]
extract_dir = temp_dir / "extract"
extract_dir.mkdir()
# Extract only first member
archive.extractall(path=extract_dir, members=[first_member])
# Verify only one file was extracted
extracted_files = list(extract_dir.glob("*.txt"))
assert len(extracted_files) == 1
-374
View File
@@ -1,374 +0,0 @@
"""Tests to cover missing lines in core.py for improved coverage."""
import tarfile
from unittest.mock import MagicMock, patch
import pytest
from tzst.core import TzstArchive
from tzst.exceptions import TzstArchiveError, TzstDecompressionError
class TestCoreMissingLines:
"""Test specific missing lines in core.py."""
def test_append_mode_error_handling(self, temp_dir):
"""Test append mode error handling (lines 126-137)."""
archive_path = temp_dir / "test.tzst"
# Test append mode raises NotImplementedError
with pytest.raises(
NotImplementedError, match="Append mode is not currently supported"
):
TzstArchive(archive_path, mode="a")
def test_invalid_mode_error_after_open(self, temp_dir):
"""Test invalid mode error in __enter__ method (line 137)."""
archive_path = temp_dir / "test.tzst"
# Create archive instance with invalid mode after validation passes
archive = TzstArchive.__new__(TzstArchive)
archive.filename = archive_path
archive.mode = "invalid" # Set invalid mode after construction
archive.compression_level = 3
archive.streaming = False
archive._tarfile = None
archive._fileobj = None
archive._compressed_stream = None
with pytest.raises(TzstArchiveError, match="Failed to open archive"):
archive.__enter__()
def test_zstd_error_handling_in_open(self, temp_dir):
"""Test zstd error handling during archive opening (lines 133-137)."""
archive_path = temp_dir / "test.tzst"
# Create a file that will cause zstd decompression error
archive_path.write_bytes(b"invalid zstd data")
# Try to open as read mode - should raise TzstDecompressionError
with pytest.raises(TzstDecompressionError, match="Failed to open archive"):
with TzstArchive(archive_path, mode="r"):
pass
def test_generic_error_handling_in_open(self, temp_dir):
"""Test generic error handling during archive opening."""
archive_path = temp_dir / "test.tzst"
# Mock to raise a generic exception (not zstd-related)
with patch("builtins.open", side_effect=PermissionError("Permission denied")):
with pytest.raises(TzstArchiveError, match="Failed to open archive"):
with TzstArchive(archive_path, mode="r"):
pass
def test_close_error_handling(self, temp_dir):
"""Test error handling in close method (lines 146-157)."""
archive_path = temp_dir / "test.tzst"
# Create archive and manually set objects that will raise on close
with TzstArchive(archive_path, mode="w") as archive:
pass
# Now manually create problematic objects
archive = TzstArchive.__new__(TzstArchive)
archive._tarfile = MagicMock()
archive._tarfile.close.side_effect = Exception("Close error")
archive._compressed_stream = MagicMock()
archive._compressed_stream.close.side_effect = Exception("Close error")
archive._fileobj = MagicMock()
archive._fileobj.close.side_effect = Exception("Close error")
# close() should handle exceptions gracefully
archive.close() # Should not raise
assert archive._tarfile is None
assert archive._compressed_stream is None
assert archive._fileobj is None
def test_archive_not_open_for_reading_errors(self, temp_dir):
"""Test RuntimeError for operations on archives not open for reading."""
archive_path = temp_dir / "test.tzst"
# Create archive in write mode
with TzstArchive(archive_path, mode="w") as archive:
# Test getmembers() on write mode
with pytest.raises(RuntimeError, match="Archive not open for reading"):
archive.getmembers()
# Test getnames() on write mode
with pytest.raises(RuntimeError, match="Archive not open for reading"):
archive.getnames()
# Test extractfile() on write mode
with pytest.raises(RuntimeError, match="Archive not open for reading"):
archive.extractfile("test")
def test_streaming_member_extraction_error(self, temp_dir):
"""Test streaming mode member extraction error."""
# Create a test archive first
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
archive.add(str(test_file), arcname="test.txt")
# Try to extract specific member in streaming mode
with TzstArchive(archive_path, mode="r", streaming=True) as archive:
extract_dir = temp_dir / "extract"
extract_dir.mkdir()
# Should raise RuntimeError for specific member extraction in streaming mode
with pytest.raises(
RuntimeError,
match="Extracting specific members is not supported in streaming mode",
):
archive.extract(member="test.txt", path=extract_dir)
def test_streaming_extraction_failure_handling(self, temp_dir):
"""Test streaming extraction failure handling."""
# Create archive first
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
archive.add(str(test_file), arcname="test.txt")
# Mock tarfile to raise StreamError
with TzstArchive(archive_path, mode="r", streaming=True) as archive:
extract_dir = temp_dir / "extract"
extract_dir.mkdir()
# Mock extract to raise StreamError with streaming-related message
with patch.object(
archive._tarfile,
"extractall",
side_effect=tarfile.StreamError("seeking not supported"),
):
with pytest.raises(
RuntimeError, match="Extraction failed in streaming mode"
):
archive.extract(path=extract_dir)
def test_extractfile_not_open_error(self, temp_dir):
"""Test extractfile when archive is not open."""
archive_path = temp_dir / "test.tzst"
# Create closed archive
archive = TzstArchive(archive_path, mode="r")
# Don't open it
with pytest.raises(RuntimeError, match="Archive not open"):
archive.extractfile("test")
def test_extractfile_write_mode_error(self, temp_dir):
"""Test extractfile in write mode."""
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
with pytest.raises(RuntimeError, match="Archive not open for reading"):
archive.extractfile("test")
def test_add_method_not_open_error(self, temp_dir):
"""Test add method when archive is not open."""
archive_path = temp_dir / "test.tzst"
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
# Create archive but don't open it
archive = TzstArchive(archive_path, mode="w")
with pytest.raises(RuntimeError, match="Archive not open"):
archive.add(str(test_file))
def test_add_method_read_mode_error(self, temp_dir):
"""Test add method in read mode."""
# Create archive first
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
archive.add(str(test_file), arcname="test.txt")
# Try to add to archive in read mode
with TzstArchive(archive_path, mode="r") as archive:
with pytest.raises(RuntimeError, match="Archive not open for writing"):
archive.add(str(test_file))
def test_file_not_found_in_add(self, temp_dir):
"""Test file not found error in add method."""
archive_path = temp_dir / "test.tzst"
missing_file = temp_dir / "missing.txt"
with TzstArchive(archive_path, mode="w") as archive:
with pytest.raises(FileNotFoundError):
archive.add(str(missing_file))
def test_add_method_generic_error_handling(self, temp_dir):
"""Test generic error handling in add method."""
archive_path = temp_dir / "test.tzst"
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
with TzstArchive(archive_path, mode="w") as archive:
# Mock add to raise generic exception
with patch.object(
archive._tarfile,
"add",
side_effect=PermissionError("Permission denied"),
):
with pytest.raises(TzstArchiveError, match="Failed to add"):
archive.add(str(test_file))
def test_test_method_not_open_error(self, temp_dir):
"""Test test method when archive is not open."""
archive_path = temp_dir / "test.tzst"
# Create archive but don't open it
archive = TzstArchive(archive_path, mode="r")
with pytest.raises(RuntimeError, match="Archive not open"):
archive.test()
def test_test_method_write_mode_error(self, temp_dir):
"""Test test method in write mode."""
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
with pytest.raises(RuntimeError, match="Archive not open for reading"):
archive.test()
def test_test_method_streaming_mode_info(self, temp_dir):
"""Test test method streaming mode information."""
# Create archive first
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
archive.add(str(test_file), arcname="test.txt")
# Test in streaming mode - should provide different behavior info
with TzstArchive(archive_path, mode="r", streaming=True) as archive:
# This should work but may have streaming-specific behavior
result = archive.test()
assert isinstance(result, bool)
def test_list_method_not_open_error(self, temp_dir):
"""Test list method when archive is not open."""
archive_path = temp_dir / "test.tzst"
# Create archive but don't open it
archive = TzstArchive(archive_path, mode="r")
with pytest.raises(RuntimeError, match="Archive not open"):
list(archive.list())
def test_list_method_write_mode_error(self, temp_dir):
"""Test list method in write mode."""
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
with pytest.raises(RuntimeError, match="Archive not open for reading"):
list(archive.list())
def test_context_manager_exception_handling(self, temp_dir):
"""Test context manager exception handling."""
archive_path = temp_dir / "test.tzst"
# Store references for cleanup
fileobj = None
compressed_stream = None
tarfile_obj = None
# Test that close exceptions are suppressed during context manager exit
with patch("tzst.core.TzstArchive.close", side_effect=Exception("Close error")):
try:
with TzstArchive(archive_path, mode="w") as archive:
# Store references to underlying objects for manual cleanup
fileobj = archive._fileobj
compressed_stream = archive._compressed_stream
tarfile_obj = archive._tarfile
raise ValueError("Test exception")
except ValueError:
pass # Expected - the original exception should not be masked
finally:
# Manually clean up since mocked close() failed
try:
if tarfile_obj:
tarfile_obj.close()
except Exception:
pass
try:
if compressed_stream:
compressed_stream.close()
except Exception:
pass
try:
if fileobj:
fileobj.close()
except Exception:
pass
# Ensure the file is removed to prevent permission errors
try:
if archive_path.exists():
archive_path.unlink()
except (PermissionError, OSError):
pass
# The close exception should be suppressed by __exit__
def test_list_verbose_mode_edge_cases(self, temp_dir):
"""Test list method verbose mode edge cases."""
# Create archive with special files
test_file = temp_dir / "test.txt"
test_file.write_text("test content")
# Create a directory
test_dir = temp_dir / "test_dir"
test_dir.mkdir()
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
archive.add(str(test_file), arcname="test.txt")
archive.add(str(test_dir), arcname="test_dir")
with TzstArchive(archive_path, mode="r") as archive:
# Test verbose listing
items = list(archive.list(verbose=True))
assert len(items) >= 2
# Should have both file and directory entries
file_items = [item for item in items if item.get("is_file", False)]
dir_items = [item for item in items if item.get("is_dir", False)]
assert len(file_items) >= 1
assert len(dir_items) >= 1
def test_extract_with_members_parameter(self, temp_dir):
"""Test extract with specific member for selective extraction."""
# Create archive with multiple files
test_file1 = temp_dir / "test1.txt"
test_file1.write_text("content1")
test_file2 = temp_dir / "test2.txt"
test_file2.write_text("content2")
archive_path = temp_dir / "test.tzst"
with TzstArchive(archive_path, mode="w") as archive:
archive.add(str(test_file1), arcname="test1.txt")
archive.add(str(test_file2), arcname="test2.txt")
# Extract only specific member
with TzstArchive(archive_path, mode="r") as archive:
extract_dir = temp_dir / "extract"
extract_dir.mkdir()
# Extract only first member
archive.extract(member="test1.txt", path=extract_dir)
# Verify only one file was extracted
extracted_files = list(extract_dir.glob("*.txt"))
assert len(extracted_files) == 1
assert extracted_files[0].name == "test1.txt"
+5
View File
@@ -1,8 +1,11 @@
"""Tests for TzstArchive class core functionality."""
import pytest
from tzst import TzstArchive
@pytest.mark.unit
class TestBasicImportAndCreation:
"""Test basic import and creation functionality."""
@@ -17,6 +20,7 @@ class TestBasicImportAndCreation:
assert archive.mode == "r"
@pytest.mark.unit
class TestTzstArchiveBasics:
"""Test basic TzstArchive class functionality."""
@@ -107,6 +111,7 @@ class TestTzstArchiveBasics:
assert "gid" in item
@pytest.mark.unit
class TestTzstArchiveStreamingMode:
"""Test streaming mode functionality."""
+4
View File
@@ -6,6 +6,7 @@ from tzst import create_archive, extract_archive, list_archive
from tzst import test_archive as tzst_test_archive
@pytest.mark.unit
class TestConvenienceFunctions:
"""Test the convenience functions."""
@@ -111,6 +112,7 @@ class TestConvenienceFunctions:
assert extract_dir_streaming.exists()
@pytest.mark.unit
class TestAtomicOperations:
"""Test atomic file operations."""
@@ -154,6 +156,7 @@ class TestAtomicOperations:
assert len(temp_files) == 0
@pytest.mark.unit
class TestCompressionLevels:
"""Test compression level validation and functionality."""
@@ -180,6 +183,7 @@ class TestCompressionLevels:
assert "1" in str(exc_info.value) and "22" in str(exc_info.value)
@pytest.mark.unit
class TestEdgeCaseCoverage:
"""Test edge cases to improve coverage."""
+6 -4
View File
@@ -8,6 +8,7 @@ from tzst import TzstArchive, create_archive, extract_archive
from tzst import test_archive as tzst_test_archive
@pytest.mark.unit
class TestErrorHandling:
"""Test error handling."""
@@ -37,6 +38,7 @@ class TestErrorHandling:
create_archive(archive_path, [fake_file])
@pytest.mark.unit
class TestSecurityFiltering:
"""Test security filtering mechanisms."""
@@ -50,11 +52,11 @@ class TestSecurityFiltering:
# Extract with tar filter
extract_dir = temp_dir / "tar_filtered"
with patch("tzst.core.TzstArchive.extract") as mock_extract:
with patch("tzst.core.TzstArchive.extractall") as mock_extractall:
extract_archive(archive_path, extract_dir, filter="tar")
# Verify filter was passed
call_args = mock_extract.call_args
call_args = mock_extractall.call_args
assert call_args[1]["filter"] == "tar"
def test_data_filter_extraction(self, sample_files, temp_dir):
@@ -67,11 +69,11 @@ class TestSecurityFiltering:
# Extract with data filter (default for security)
extract_dir = temp_dir / "data_filtered"
with patch("tzst.core.TzstArchive.extract") as mock_extract:
with patch("tzst.core.TzstArchive.extractall") as mock_extractall:
extract_archive(archive_path, extract_dir, filter="data")
# Verify filter was passed
call_args = mock_extract.call_args
call_args = mock_extractall.call_args
assert call_args[1]["filter"] == "data"
def test_invalid_filter_raises_error(self, sample_files, temp_dir):