diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecfdb2e..01a2813 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,9 +33,9 @@ jobs: run: | ruff check src tests - - name: Format check with black + - name: Format check with ruff run: | - black --check src tests + ruff format --check src tests - name: Run tests run: | diff --git a/pyproject.toml b/pyproject.toml index e72e1f3..62b8b02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,6 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", - "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: System :: Archiving :: Compression", @@ -25,7 +24,7 @@ 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", "black>=23.0.0"] +dev = ["pytest>=7.0.0", "pytest-cov>=4.0.0", "ruff>=0.1.0"] [project.urls] Homepage = "https://github.com/xixu-me/tzst" @@ -43,7 +42,14 @@ path = "src/tzst/__init__.py" packages = ["src/tzst"] [tool.hatch.build.targets.sdist] -include = ["/src", "/tests", "/README.md", "/LICENSE"] +include = [ + "src", + "tests", + "README.md", + "LICENSE", + "CHANGELOG.md", + "CONTRIBUTING.md", +] [tool.pytest.ini_options] testpaths = ["tests"] @@ -66,13 +72,7 @@ select = [ "C4", # flake8-comprehensions "UP", # pyupgrade ] -ignore = [ - "E501", # line too long, handled by black -] -[tool.ruff.lint.per-file-ignores] -"tests/**/*" = ["E501"] - -[tool.black] -target-version = ["py312", "py313"] -line-length = 88 +[tool.ruff.format] +quote-style = "double" +indent-style = "space" diff --git a/src/tzst/cli.py b/src/tzst/cli.py index fb12f85..26ef60d 100644 --- a/src/tzst/cli.py +++ b/src/tzst/cli.py @@ -10,13 +10,38 @@ from .exceptions import TzstArchiveError, TzstDecompressionError def print_banner() -> None: - """Print the version and copyright banner.""" + """Print the version and copyright banner. + + Displays the tzst version number and copyright information to stdout. + Used as a header for CLI operations. + + Returns: + None + """ print() print(f"tzst {__version__} : Copyright (c) 2025 Xi Xu") def format_size(size: int) -> str: - """Format file size in human-readable format.""" + """Format file size in human-readable format. + + Converts byte values to human-readable format using standard units + (B, KB, MB, GB, TB, PB) with appropriate decimal places. + + Args: + size (int): Size in bytes to format + + Returns: + str: Formatted size string with units (e.g., "1.5 KB", "2.3 GB") + + Examples: + >>> format_size(1024) + ' 1.0 KB' + >>> format_size(1536) + ' 1.5 KB' + >>> format_size(2048576) + ' 2.0 MB' + """ size_float = float(size) for unit in ["B", "KB", "MB", "GB", "TB"]: if size_float < 1024.0: @@ -26,7 +51,33 @@ def format_size(size: int) -> str: def cmd_add(args) -> int: - """Add/create archive command with atomic file operations.""" + """Command handler for creating/adding to archives. + + Processes the 'add', 'create', or 'a' CLI commands to create new tzst archives + with the specified files and directories. Uses atomic file operations by + default to ensure data integrity. + + Args: + args: Parsed command line arguments containing: + - archive (str): Path to the archive file to create + - files (list[str]): List of files/directories to add + - compression_level (int, optional): Compression level 1-22 + - no_atomic (bool, optional): Disable atomic file operations + + Returns: + int: Exit code (0 for success, non-zero for failure) + - 0: Success + - 1: File not found, invalid parameters, or archive operation failed + - 130: Operation interrupted by user (Ctrl+C) + + Note: + This function uses atomic file operations by default, creating the + archive in a temporary file first, then atomically moving it to the + final location to prevent incomplete archives. + + See Also: + :func:`tzst.create_archive`: The underlying function for archive creation + """ try: archive_path = Path(args.archive) files: list[Path] = [Path(f) for f in args.files] @@ -48,7 +99,7 @@ def cmd_add(args) -> int: print(f" Adding: {file_path}") # Use atomic file operations by default for better reliability - # This creates the archive in a temporary file first, then moves it atomically + # This creates the archive in a temporary file first, then moves it create_archive( archive_path, files, compression_level, use_temp_file=use_temp_file ) @@ -66,7 +117,8 @@ def cmd_add(args) -> int: return 1 except KeyboardInterrupt: print("\nOperation interrupted by user", file=sys.stderr) - # Clean up any partial files - the atomic operations in create_archive handle this + # Clean up any partial files - the atomic operations in create_archive + # handle this return 130 # Standard exit code for SIGINT except Exception as e: print(f"Error: Failed to create archive - {e}", file=sys.stderr) @@ -74,7 +126,33 @@ def cmd_add(args) -> int: def cmd_extract_full(args) -> int: - """Extract with full paths command.""" + """Command handler for extracting archives with full directory structure. + + Processes the 'extract' or 'x' CLI commands to extract files from tzst + archives while preserving the original directory structure. + + Args: + args: Parsed command line arguments containing: + - archive (str): Path to the archive file to extract + - output (str, optional): Output directory path + - files (list[str], optional): Specific files to extract + - streaming (bool, optional): Use streaming mode for large archives + - filter (str, optional): Security filter ('data', 'tar', 'fully_trusted') + + Returns: + int: Exit code (0 for success, non-zero for failure) + - 0: Success + - 1: File not found, decompression failed, or archive operation failed + - 130: Operation interrupted by user (Ctrl+C) + + Note: + Uses the 'data' security filter by default for safe extraction from + untrusted sources. Streaming mode is recommended for archives > 100MB. + + See Also: + :func:`tzst.extract_archive`: The underlying function for extraction + :func:`cmd_extract_flat`: For flat extraction without directory structure + """ try: archive_path = Path(args.archive) if not archive_path.exists(): @@ -122,7 +200,34 @@ def cmd_extract_full(args) -> int: def cmd_extract_flat(args) -> int: - """Extract without paths (flat) command.""" + """Command handler for flat extraction without directory structure. + + Processes the 'extract-flat' or 'e' CLI commands to extract files from + tzst archives without preserving directory structure (all files extracted + to a single directory). + + Args: + args: Parsed command line arguments containing: + - archive (str): Path to the archive file to extract + - output (str, optional): Output directory path + - files (list[str], optional): Specific files to extract + - streaming (bool, optional): Use streaming mode for large archives + - filter (str, optional): Security filter ('data', 'tar', 'fully_trusted') + + Returns: + int: Exit code (0 for success, non-zero for failure) + - 0: Success + - 1: File not found, decompression failed, or archive operation failed + - 130: Operation interrupted by user (Ctrl+C) + + Warning: + Flat extraction may cause filename conflicts if multiple files have + the same name but are in different directories within the archive. + + See Also: + :func:`tzst.extract_archive`: The underlying function for extraction + :func:`cmd_extract_full`: For extraction with directory structure + """ try: archive_path = Path(args.archive) if not archive_path.exists(): @@ -165,7 +270,30 @@ def cmd_extract_flat(args) -> int: def cmd_list(args) -> int: - """List contents of archive command.""" + """Command handler for listing archive contents. + + Processes the 'list' or 'l' CLI commands to display the contents of tzst + archives. Supports both simple and verbose listing modes. + + Args: + args: Parsed command line arguments containing: + - archive (str): Path to the archive file to list + - verbose (bool, optional): Show detailed file information + - streaming (bool, optional): Use streaming mode for large archives + + Returns: + int: Exit code (0 for success, non-zero for failure) + - 0: Success + - 1: File not found, decompression failed, or archive operation failed + - 130: Operation interrupted by user (Ctrl+C) + + Note: + Verbose mode displays file permissions, sizes, modification times, + and other metadata. Streaming mode is recommended for archives > 100MB. + + See Also: + :func:`tzst.list_archive`: The underlying function for listing contents + """ try: archive_path = Path(args.archive) if not archive_path.exists(): @@ -208,9 +336,11 @@ def cmd_list(args) -> int: print(item["name"]) print() - print( - f"Total: {total_files} files, {total_dirs} directories, {format_size(total_size)}" + total_msg = ( + f"Total: {total_files} files, {total_dirs} directories, " + f"{format_size(total_size)}" ) + print(total_msg) return 0 @@ -229,7 +359,30 @@ def cmd_list(args) -> int: def cmd_test(args) -> int: - """Test integrity of archive command.""" + """Command handler for testing archive integrity. + + Processes the 'test' or 't' CLI commands to verify the integrity of tzst + archives by attempting to read all files and checking for corruption. + + Args: + args: Parsed command line arguments containing: + - archive (str): Path to the archive file to test + - streaming (bool, optional): Use streaming mode for large archives + + Returns: + int: Exit code (0 for success, non-zero for failure) + - 0: Archive passed integrity test + - 1: Archive failed integrity test, file not found, or operation failed + - 130: Operation interrupted by user (Ctrl+C) + + Note: + This command verifies that the archive can be read and all files + can be decompressed without errors. Streaming mode is recommended + for archives > 100MB to reduce memory usage. + + See Also: + :func:`tzst.test_archive`: The underlying function for integrity testing + """ try: archive_path = Path(args.archive) if not archive_path.exists(): @@ -264,14 +417,40 @@ def cmd_test(args) -> int: def create_parser() -> argparse.ArgumentParser: + """Create and configure the command-line argument parser. + + Sets up the argparse ArgumentParser with all subcommands and their + respective arguments for the tzst CLI interface. Includes comprehensive + help text and command reference documentation. + + Returns: + argparse.ArgumentParser: Configured parser ready for argument parsing + + Note: + The parser is configured with RawDescriptionHelpFormatter to preserve + formatting in the epilog help text, and includes detailed command + reference and security notes. + + Commands Created: + - a, add, create: Archive creation with compression levels + - x, extract: Full extraction with directory structure + - e, extract-flat: Flat extraction without directories + - l, list: Archive content listing + - t, test: Archive integrity testing + + See Also: + :func:`main`: The main entry point that uses this parser + """ epilog = """ Command Reference: Archive: a, add, create tzst a archive.tzst files... [-l LEVEL] [--no-atomic] Extract: - x, extract tzst x archive.tzst [files...] [-o DIR] [--streaming] [--filter FILTER] - e, extract-flat tzst e archive.tzst [files...] [-o DIR] [--streaming] [--filter FILTER] + x, extract tzst x archive.tzst [files...] [-o DIR] [--streaming] \\ + [--filter FILTER] + e, extract-flat tzst e archive.tzst [files...] [-o DIR] [--streaming] \\ + [--filter FILTER] Manage: l, list tzst l archive.tzst [-v] [--streaming] @@ -281,8 +460,10 @@ Arguments: -l, --level LEVEL Compression level (1-22, default: 3) -o, --output DIR Output directory (default: current directory) -v, --verbose Show detailed information - --streaming Use streaming mode for memory efficiency with large archives - --filter FILTER Security filter for extraction: data (safest, default), tar, fully_trusted + --streaming Use streaming mode for memory efficiency with large \\ + archives + --filter FILTER Security filter for extraction: data (safest, default), \\ + tar, fully_trusted --no-atomic Disable atomic file operations (not recommended) Security Note: @@ -324,7 +505,10 @@ Documentation: parser_add.add_argument( "--no-atomic", action="store_true", - help="Disable atomic file operations (not recommended - creates archive directly without temporary file)", + help=( + "Disable atomic file operations (not recommended - creates archive " + "directly without temporary file)" + ), ) parser_add.set_defaults(func=cmd_add) @@ -346,7 +530,11 @@ Documentation: "--filter", choices=["data", "tar", "fully_trusted"], default="data", - help="Extraction filter for security (default: data). 'data' is safest for untrusted archives, 'tar' honors most tar features, 'fully_trusted' honors all metadata", + help=( + "Extraction filter for security (default: data). 'data' is safest " + "for untrusted archives, 'tar' honors most tar features, " + "'fully_trusted' honors all metadata" + ), ) parser_extract.set_defaults(func=cmd_extract_full) @@ -372,7 +560,11 @@ Documentation: "--filter", choices=["data", "tar", "fully_trusted"], default="data", - help="Extraction filter for security (default: data). 'data' is safest for untrusted archives, 'tar' honors most tar features, 'fully_trusted' honors all metadata", + help=( + "Extraction filter for security (default: data). 'data' is safest " + "for untrusted archives, 'tar' honors most tar features, " + "'fully_trusted' honors all metadata" + ), ) parser_extract_flat.set_defaults(func=cmd_extract_flat) @@ -407,7 +599,30 @@ Documentation: def main(argv: list[str] | None = None) -> int: - """Main entry point for the CLI.""" + """Main entry point for the tzst command-line interface. + + Processes command-line arguments and dispatches to appropriate command + handlers. Displays the version banner and provides error handling for + the overall CLI execution. + + Args: + argv (list[str] | None, optional): Command line arguments to parse. + If None, uses sys.argv. Defaults to None. + + Returns: + int: Exit code for the program + - 0: Success + - 1: No command specified (help displayed) + - Other codes: Specific to individual command handlers + + Note: + This function serves as the console script entry point defined in + pyproject.toml. It displays the version banner before executing + any commands. + + See Also: + :func:`create_parser`: Creates the argument parser used by this function + """ print_banner() print() diff --git a/src/tzst/exceptions.py b/src/tzst/exceptions.py index 62ead5e..785d049 100644 --- a/src/tzst/exceptions.py +++ b/src/tzst/exceptions.py @@ -2,30 +2,66 @@ class TzstError(Exception): - """Base exception for tzst operations.""" + """Base exception for all tzst operations. + + This is the parent class for all tzst-specific exceptions. + Catch this to handle any tzst-related error. + """ pass class TzstCompressionError(TzstError): - """Exception raised when compression fails.""" + """Exception raised when compression operations fail. + + This can occur when: + - Invalid compression level is specified + - Disk space is insufficient during compression + - Input data cannot be compressed due to corruption + - Zstandard compression encounters an internal error + """ pass class TzstDecompressionError(TzstError): - """Exception raised when decompression fails.""" + """Exception raised when decompression operations fail. + + This can occur when: + - Archive file is corrupted or incomplete + - Archive was not created with zstandard compression + - Decompression buffer overflows or underflows + - Archive format is invalid or unsupported + """ pass class TzstArchiveError(TzstError): - """Exception raised when archive operations fail.""" + """Exception raised when archive operations fail. + + This can occur when: + - Archive file cannot be opened or created + - File permissions prevent archive access + - Archive structure is malformed + - Tar operations fail within the archive + - Atomic file operations fail during creation + """ pass class TzstFileNotFoundError(TzstError, FileNotFoundError): - """Exception raised when a file is not found.""" + """Exception raised when a required file is not found. + + This can occur when: + - Archive file does not exist for reading operations + - Input files for archiving do not exist + - Output directory cannot be created for extraction + - Temporary files cannot be created during atomic operations + + Inherits from both TzstError and FileNotFoundError for compatibility + with standard Python exception handling patterns. + """ pass