From 6cc8f28175ec4d47ad72d48d7e6a5eb6d0109546 Mon Sep 17 00:00:00 2001 From: Xi Xu Date: Sat, 31 May 2025 22:49:41 +0800 Subject: [PATCH] Refactor CLI and update CI workflow Updated the CI workflow to use 'ruff format' instead of 'black' for format checks. Enhanced docstrings across CLI commands and exception classes for better clarity and documentation. Adjusted pyproject.toml to remove 'black' from dev dependencies and added new files to sdist include list. --- .github/workflows/ci.yml | 4 +- pyproject.toml | 24 ++-- src/tzst/cli.py | 253 ++++++++++++++++++++++++++++++++++++--- src/tzst/exceptions.py | 46 ++++++- 4 files changed, 289 insertions(+), 38 deletions(-) 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