From 5b137121899380b08e8f99fe982d9134dd3633d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=BC=D1=8D=D0=BB=D1=8C=D0=BA=D0=B0?= Date: Tue, 16 Dec 2025 20:27:23 +1000 Subject: [PATCH 1/3] Refactor config and add project initialization function Refactor TabFixConfig class and add init_project function. --- src/tabfix/config.py | 95 +++++++++++++++++++------------------------- 1 file changed, 40 insertions(+), 55 deletions(-) diff --git a/src/tabfix/config.py b/src/tabfix/config.py index c329de6..a1d2fc9 100644 --- a/src/tabfix/config.py +++ b/src/tabfix/config.py @@ -1,10 +1,9 @@ #!/usr/bin/env python3 import json -import os from pathlib import Path from typing import Any, Dict, Optional from dataclasses import dataclass, field, asdict - +import os try: import tomllib @@ -19,7 +18,6 @@ @dataclass class TabFixConfig: - """Configuration for tabfix tool.""" spaces: int = 4 fix_mixed: bool = True fix_trailing: bool = True @@ -40,45 +38,32 @@ class TabFixConfig: verbose: bool = False quiet: bool = False no_color: bool = False - - # Git options + git_staged: bool = False git_unstaged: bool = False git_all_changed: bool = False no_gitignore: bool = False - - # Path patterns + include_patterns: list = field(default_factory=list) exclude_patterns: list = field(default_factory=list) - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "TabFixConfig": - """Create config from dictionary.""" - valid_fields = {f.name for f in field(cls)} - filtered_data = {k: v for k, v in data.items() if k in valid_fields} - return cls(**filtered_data) - + def to_dict(self) -> Dict[str, Any]: - """Convert config to dictionary.""" return asdict(self) - + + def update_from_dict(self, data: Dict[str, Any]): + for key, value in data.items(): + if hasattr(self, key): + setattr(self, key, value) + def update_from_args(self, args): - """Update config from argparse namespace.""" for key, value in vars(args).items(): if hasattr(self, key) and value is not None: - # Only update if value is not default (for booleans, check if explicitly set) - if isinstance(value, bool) and value == False and key not in vars(args): - # Skip False booleans that might be defaults - continue setattr(self, key, value) class ConfigLoader: - """Load configuration from various file formats.""" - @staticmethod def find_config_file(start_dir: Path) -> Optional[Path]: - """Find configuration file in directory hierarchy.""" config_names = [ ".tabfixrc", ".tabfixrc.json", @@ -88,7 +73,7 @@ def find_config_file(start_dir: Path) -> Optional[Path]: "pyproject.toml", "tabfix.json", ] - + current = start_dir while current != current.parent: for name in config_names: @@ -97,23 +82,22 @@ def find_config_file(start_dir: Path) -> Optional[Path]: return config_path current = current.parent return None - + @staticmethod def load_config(config_path: Path) -> Dict[str, Any]: - """Load configuration from file.""" suffix = config_path.suffix.lower() - + if suffix == ".toml": if not TOML_AVAILABLE: raise ImportError("TOML support requires tomllib (Python 3.11+) or tomli") - + with open(config_path, "rb") as f: data = tomllib.load(f) - + if config_path.name == "pyproject.toml": return data.get("tool", {}).get("tabfix", {}) return data - + elif suffix in [".yaml", ".yml"]: try: import yaml @@ -121,56 +105,57 @@ def load_config(config_path: Path) -> Dict[str, Any]: return yaml.safe_load(f) except ImportError: raise ImportError("YAML support requires PyYAML") - + elif suffix == ".json" or config_path.name == ".tabfixrc": with open(config_path, "r", encoding="utf-8") as f: return json.load(f) - + else: return {} - + @staticmethod def save_config(config: TabFixConfig, config_path: Path) -> bool: - """Save configuration to file.""" suffix = config_path.suffix.lower() - + try: if suffix == ".json" or config_path.name == ".tabfixrc": with open(config_path, "w", encoding="utf-8") as f: json.dump(config.to_dict(), f, indent=2) - + elif suffix == ".toml": if not TOML_AVAILABLE: raise ImportError("TOML support requires tomllib or tomli") - + import tomli_w with open(config_path, "wb") as f: tomli_w.dump(config.to_dict(), f) - + elif suffix in [".yaml", ".yml"]: import yaml with open(config_path, "w", encoding="utf-8") as f: yaml.dump(config.to_dict(), f, default_flow_style=False) - + else: return False - + return True - + except Exception as e: print(f"Error saving config: {e}") return False -class TabFixConfig: - def update_from_dict(self, data: dict): - for key, value in data.items(): - if hasattr(self, key): - setattr(self, key, value) - - def update_from_args(self, args): - for key in vars(args): - if hasattr(self, key): - value = getattr(args, key) - if value is not None: - setattr(self, key, value) +def init_project(root_dir: Path) -> bool: + config_path = root_dir / ".tabfixrc" + + if config_path.exists(): + print(f"Configuration file already exists at {config_path}") + return False + + config = TabFixConfig() + if ConfigLoader.save_config(config, config_path): + print(f"Created configuration file at {config_path}") + return True + else: + print("Failed to create configuration file") + return False From 63cfe9b578c900f79eac6e73f9d741b7d3e16e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=BC=D1=8D=D0=BB=D1=8C=D0=BA=D0=B0?= Date: Tue, 16 Dec 2025 20:27:53 +1000 Subject: [PATCH 2/3] Update version and clean up imports in __init__.py --- src/tabfix/__init__.py | 152 ++--------------------------------------- 1 file changed, 4 insertions(+), 148 deletions(-) diff --git a/src/tabfix/__init__.py b/src/tabfix/__init__.py index ae13276..8eb8e1c 100644 --- a/src/tabfix/__init__.py +++ b/src/tabfix/__init__.py @@ -1,159 +1,15 @@ -__version__ = "1.2.5" +__version__ = "1.2.5.2" -# Re-export core functionality from .core import TabFix, Colors, print_color, GitignoreMatcher from .config import TabFixConfig, ConfigLoader -# API functions will be defined inline to avoid import issues -import sys -from pathlib import Path -from typing import List, Tuple, Dict, Any, Optional -from dataclasses import dataclass, asdict - - -# Define TabFixConfig inline to avoid circular imports -@dataclass -class TabFixConfig: - spaces: int = 4 - fix_mixed: bool = True - fix_trailing: bool = True - final_newline: bool = True - remove_bom: bool = False - keep_bom: bool = False - format_json: bool = True - max_file_size: int = 10 * 1024 * 1024 - skip_binary: bool = True - fallback_encoding: str = "latin-1" - warn_encoding: bool = False - force_encoding: Optional[str] = None - smart_processing: bool = True - preserve_quotes: bool = False - progress: bool = False - dry_run: bool = False - backup: bool = False - verbose: bool = False - quiet: bool = True - no_color: bool = True - git_staged: bool = False - git_unstaged: bool = False - git_all_changed: bool = False - no_gitignore: bool = False - recursive: bool = False - interactive: bool = False - - def to_dict(self) -> Dict[str, Any]: - return asdict(self) - - def update_from_dict(self, data: dict): - for key, value in data.items(): - if hasattr(self, key): - setattr(self, key, value) - - def update_from_args(self, args): - for key in vars(args): - if hasattr(self, key): - value = getattr(args, key) - if value is not None: - setattr(self, key, value) - - -# API helper functions -def fix_string(content: str, spaces: int = 4, **kwargs) -> Tuple[str, List[str]]: - """Fix indentation and formatting in a string.""" - config = TabFixConfig(spaces=spaces, **kwargs) - tabfix = TabFix(spaces_per_tab=config.spaces) - - changes = [] - fixed_content = content - - if config.fix_mixed: - fixed_content, indent_changes = tabfix.fix_mixed_indentation(fixed_content) - changes.extend(indent_changes) - - if config.fix_trailing: - fixed_content, trailing_changes = tabfix.fix_trailing_spaces(fixed_content) - changes.extend(trailing_changes) - - if config.final_newline: - fixed_content, newline_changes = tabfix.ensure_final_newline(fixed_content) - changes.extend(newline_changes) - - return fixed_content, changes - - -def fix_file(filepath: Path, spaces: int = 4, **kwargs) -> Tuple[bool, List[str]]: - """Fix a single file.""" - config = TabFixConfig(spaces=spaces, **kwargs) - tabfix = TabFix(spaces_per_tab=config.spaces) - - class Args: - def __init__(self, config): - for key, value in config.to_dict().items(): - setattr(self, key, value) - - args = Args(config) - return tabfix.process_file(filepath, args, None) - - -def check_file(filepath: Path, spaces: int = 4, **kwargs) -> Tuple[bool, List[str]]: - """Check if a file needs fixing.""" - config = TabFixConfig(spaces=spaces, **kwargs) - config.dry_run = True - config.check_only = True - tabfix = TabFix(spaces_per_tab=config.spaces) - - class Args: - def __init__(self, config): - for key, value in config.to_dict().items(): - setattr(self, key, value) - - args = Args(config) - return tabfix.process_file(filepath, args, None) - - -def detect_indentation(content: str) -> Dict[str, Any]: - """Detect indentation style in content.""" - tabfix = TabFix() - return tabfix.detect_indentation(content) - - -def create_config_file(filepath: Path, config: Optional[TabFixConfig] = None): - """Create a configuration file.""" - import json - config = config or TabFixConfig() - with open(filepath, 'w') as f: - json.dump(config.to_dict(), f, indent=2) - - -# For backwards compatibility -class TabFixAPI: - def __init__(self, config: Optional[TabFixConfig] = None): - self.config = config or TabFixConfig() - self.tabfix = TabFix(spaces_per_tab=self.config.spaces) - - def fix_string(self, content: str, filepath: Optional[Path] = None) -> Tuple[str, List[str]]: - return fix_string(content, **self.config.to_dict()) - - def fix_file(self, filepath: Path) -> Tuple[bool, List[str]]: - return fix_file(filepath, **self.config.to_dict()) - - def check_file(self, filepath: Path) -> Tuple[bool, List[str]]: - return check_file(filepath, **self.config.to_dict()) - - def detect_indentation(self, content: str) -> Dict[str, Any]: - return detect_indentation(content) - __all__ = [ "TabFix", - "Colors", + "Colors", "print_color", "GitignoreMatcher", "TabFixConfig", - "TabFixAPI", - "fix_string", - "fix_file", - "check_file", - "detect_indentation", - "create_config_file", + "ConfigLoader", + "__version__", ] From 98f4aa4415ef9d14067a67ea17efc7dead4bff86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A8=D0=BC=D1=8D=D0=BB=D1=8C=D0=BA=D0=B0?= Date: Tue, 16 Dec 2025 20:28:15 +1000 Subject: [PATCH 3/3] Update argument parser in __main__.py Refactor argument parser to include new options and remove deprecated ones. --- src/tabfix/__main__.py | 390 ++++++++++------------------------------- 1 file changed, 88 insertions(+), 302 deletions(-) diff --git a/src/tabfix/__main__.py b/src/tabfix/__main__.py index 9d603b0..3563b0c 100644 --- a/src/tabfix/__main__.py +++ b/src/tabfix/__main__.py @@ -2,10 +2,9 @@ import sys import argparse from pathlib import Path -from typing import List, Optional -from .core import TabFix, Colors, print_color -from .config import TabFixConfig, ConfigLoader +from .core import TabFix, Colors, print_color, GitignoreMatcher +from .config import TabFixConfig, ConfigLoader, init_project def create_parser() -> argparse.ArgumentParser: @@ -13,91 +12,33 @@ def create_parser() -> argparse.ArgumentParser: description="Advanced tab/space indentation fixer with extended features", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" -Configuration: - tabfix will look for configuration files in the following order: - 1. .tabfixrc, .tabfixrc.json, .tabfixrc.toml, .tabfixrc.yaml - 2. pyproject.toml (in [tool.tabfix] section) - 3. tabfix.json - - Command line arguments override configuration file settings. - Examples: tabfix --init # Create .tabfixrc config file - tabfix --pre-commit # Generate pre-commit hook config tabfix --recursive --remove-bom # Process recursively, remove BOM tabfix --git-staged --interactive # Interactive mode on staged files + tabfix --diff file1.py file2.py # Compare indentation """, ) - - - config_group = parser.add_argument_group("Configuration") - config_group.add_argument( - "--config", - type=Path, - help="Path to configuration file" - ) - config_group.add_argument( - "--no-config", - action="store_true", - help="Ignore configuration files" - ) - config_group.add_argument( - "--init", - action="store_true", - help="Initialize configuration file (.tabfixrc)" - ) - config_group.add_argument( - "--show-config", - action="store_true", - help="Show current configuration and exit" - ) - - + parser.add_argument( "paths", nargs="*", default=["."], help="Files or directories to process" ) - - formatting_group = parser.add_argument_group("Formatting options") - formatting_group.add_argument( + + parser.add_argument( "-s", "--spaces", type=int, + default=4, help="Number of spaces per tab (default: 4)" ) - formatting_group.add_argument( - "-m", "--fix-mixed", - action="store_true", - help="Fix mixed tabs/spaces indentation" - ) - formatting_group.add_argument( - "-t", "--fix-trailing", - action="store_true", - help="Remove trailing whitespace" - ) - formatting_group.add_argument( - "-f", "--final-newline", - action="store_true", - help="Ensure file ends with newline" - ) - formatting_group.add_argument( - "--remove-bom", - action="store_true", - help="Remove UTF-8 BOM marker" - ) - formatting_group.add_argument( - "--keep-bom", - action="store_true", - help="Preserve existing BOM marker" - ) - formatting_group.add_argument( - "--format-json", + parser.add_argument( + "-r", "--recursive", action="store_true", - help="Format JSON files with proper indentation" + help="Process directories recursively" ) - - + git_group = parser.add_argument_group("Git integration") git_group.add_argument( "--git-staged", @@ -119,273 +60,120 @@ def create_parser() -> argparse.ArgumentParser: action="store_true", help="Do not use .gitignore patterns" ) - - # Encoding and file handling - encoding_group = parser.add_argument_group("Encoding and file handling") - encoding_group.add_argument( - "--skip-binary", - action="store_true", - help="Skip files that appear to be binary" - ) - encoding_group.add_argument( - "--no-skip-binary", - action="store_false", - dest="skip_binary", - help="Process files even if they appear to be binary" - ) - encoding_group.add_argument( - "--force-encoding", - help="Force specific encoding (skip auto-detection)" - ) - encoding_group.add_argument( - "--fallback-encoding", - default="latin-1", - help="Fallback encoding when detection fails (default: latin-1)" + + parser.add_argument( + "--diff", + nargs=2, + metavar=("FILE1", "FILE2"), + help="Compare indentation between two files" ) - encoding_group.add_argument( - "--warn-encoding", + parser.add_argument( + "--format-json", action="store_true", - help="Warn when encoding detection is uncertain" + help="Format JSON files with proper indentation" ) - encoding_group.add_argument( - "--max-file-size", - type=int, - default=10 * 1024 * 1024, - help="Maximum file size to process in bytes (default: 10MB)" + parser.add_argument( + "-i", "--interactive", + action="store_true", + help="Interactive mode (confirm each change)" ) - - - filetype_group = parser.add_argument_group("File type specific processing") - filetype_group.add_argument( - "--smart-processing", + parser.add_argument( + "--progress", action="store_true", - default=True, - help="Enable smart processing for different file types (default: True)" + help="Show progress bar during processing" ) - filetype_group.add_argument( - "--no-smart-processing", - action="store_false", - dest="smart_processing", - help="Disable smart processing for different file types" + parser.add_argument( + "--remove-bom", + action="store_true", + help="Remove UTF-8 BOM marker" ) - filetype_group.add_argument( - "--preserve-quotes", + parser.add_argument( + "--keep-bom", action="store_true", - help="Preserve original string quotes in code files" + help="Preserve existing BOM marker" ) - - - mode_group = parser.add_argument_group("Operation mode") - mode_group.add_argument( - "-r", "--recursive", + + parser.add_argument( + "-m", "--fix-mixed", action="store_true", - help="Process directories recursively" + help="Fix mixed tabs/spaces indentation" ) - mode_group.add_argument( - "-i", "--interactive", + parser.add_argument( + "-t", "--fix-trailing", action="store_true", - help="Interactive mode (confirm each change)" + help="Remove trailing whitespace" ) - mode_group.add_argument( - "--progress", + parser.add_argument( + "-f", "--final-newline", action="store_true", - help="Show progress bar during processing" + help="Ensure file ends with newline" ) - mode_group.add_argument( + parser.add_argument( "--dry-run", action="store_true", help="Show changes without modifying files" ) - mode_group.add_argument( + parser.add_argument( "--backup", action="store_true", help="Create backup files (.bak)" ) - mode_group.add_argument( - "--pre-commit", - action="store_true", - help="Generate pre-commit hook configuration" - ) - - - output_group = parser.add_argument_group("Output control") - output_group.add_argument( + parser.add_argument( "-v", "--verbose", action="store_true", help="Verbose output" ) - output_group.add_argument( + parser.add_argument( "-q", "--quiet", action="store_true", help="Quiet mode (minimal output)" ) - output_group.add_argument( + parser.add_argument( "--no-color", action="store_true", help="Disable colored output" ) - + parser.add_argument( - "--diff", - nargs=2, - metavar=("FILE1", "FILE2"), - help="Compare indentation between two files" + "--init", + action="store_true", + help="Initialize configuration file (.tabfixrc)" ) - + parser.add_argument( + "--config", + type=Path, + help="Path to configuration file" + ) + return parser -def init_config() -> bool: - config = TabFixConfig() - config_path = Path.cwd() / ".tabfixrc" - - if config_path.exists(): - print_color(f"Configuration file already exists at {config_path}", Colors.YELLOW) - response = input("Overwrite? (y/n): ").lower().strip() - if response != "y": - return False - - if ConfigLoader.save_config(config, config_path): - print_color(f"✓ Created configuration file: {config_path}", Colors.GREEN) - return True - else: - print_color("✗ Failed to create configuration file", Colors.RED) - return False - - -def generate_pre_commit_config() -> bool: - try: - from tabfix import __version__ - except ImportError: - __version__ = "latest" - - config = f"""repos: - - repo: https://github.com/hairpin01/tabfix - rev: v{__version__} - hooks: - - id: tabfix - name: tabfix - entry: tabfix - args: [--fix-mixed, --fix-trailing, --final-newline] - language: python - types: [python, javascript, json, yaml, markdown, html, css] - stages: [commit] -""" - - config_path = Path.cwd() / ".pre-commit-config.yaml" - - if config_path.exists(): - print_color(f"pre-commit config already exists at {config_path}", Colors.YELLOW) - response = input("Overwrite? (y/n): ").lower().strip() - if response != "y": - return False - - with open(config_path, "w") as f: - f.write(config) - - print_color(f"✓ Created pre-commit config: {config_path}", Colors.GREEN) - print_color("\nTo use this configuration:", Colors.CYAN) - print_color("1. Install pre-commit: pip install pre-commit") - print_color("2. Install the hook: pre-commit install") - print_color("3. Run on all files: pre-commit run --all-files") - - return True - - -def show_config(config: TabFixConfig, config_path: Optional[Path] = None): - print_color("Current Configuration:", Colors.BOLD + Colors.CYAN) - if config_path: - print_color(f"Loaded from: {config_path}", Colors.BLUE) - - config_dict = config.to_dict() - for key, value in sorted(config_dict.items()): - if value is not None: - if isinstance(value, bool): - value_str = "✓" if value else "✗" - color = Colors.GREEN if value else Colors.RED - else: - value_str = str(value) - color = Colors.BLUE - - key_str = key.replace("_", " ").title() - print_color(f" {key_str:20} : {color}{value_str}{Colors.END}") - - -def load_configuration(args) -> TabFixConfig: - config = TabFixConfig() - - if not args.no_config: - config_path = args.config - if not config_path: - config_path = ConfigLoader.find_config_file(Path.cwd()) - - if config_path and config_path.exists(): - try: - config_data = ConfigLoader.load_config(config_path) - file_config = TabFixConfig.from_dict(config_data) - - - config = file_config - - if args.verbose: - print_color(f"Loaded configuration from: {config_path}", Colors.CYAN) - except Exception as e: - if not args.quiet: - print_color(f"Error loading config {config_path}: {e}", Colors.YELLOW) - - - config.update_from_args(args) - - return config - - def main(): parser = create_parser() args = parser.parse_args() - + if args.no_color: global Colors Colors = type("Colors", (), {k: "" for k in dir(Colors) if not k.startswith("_")})() - - + if args.init: - sys.exit(0 if init_config() else 1) - - if args.pre_commit: - sys.exit(0 if generate_pre_commit_config() else 1) - - if args.show_config: - config = load_configuration(args) - show_config(config) - sys.exit(0) - - - config = load_configuration(args) - - - for key, value in config.to_dict().items(): - if hasattr(args, key) and getattr(args, key) is None: - setattr(args, key, value) - - + success = init_project(Path.cwd()) + sys.exit(0 if success else 1) + if args.remove_bom and args.keep_bom: print_color("Cannot use both --remove-bom and --keep-bom", Colors.RED) sys.exit(1) - - + fixer = TabFix(spaces_per_tab=args.spaces) - - + if args.diff: file1 = Path(args.diff[0]) file2 = Path(args.diff[1]) fixer.compare_files(file1, file2, args) return - - + files_to_process = [] - - + if args.git_staged or args.git_unstaged or args.git_all_changed: if args.git_staged: files = fixer.get_git_files("staged") @@ -395,15 +183,14 @@ def main(): files = fixer.get_git_files("all_changed") files_to_process.extend(files) else: - for path_str in args.paths: path = Path(path_str) - + if not path.exists(): if not args.quiet: print_color(f"Warning: Path not found: {path}", Colors.YELLOW) continue - + if path.is_file(): files_to_process.append(path) elif path.is_dir(): @@ -411,17 +198,16 @@ def main(): pattern = "**/*" else: pattern = "*" - + for filepath in path.glob(pattern): if filepath.is_file(): files_to_process.append(filepath) - + if not files_to_process: if not args.quiet: print_color("No files to process", Colors.YELLOW) return - - + gitignore_matcher = None if not args.no_gitignore and files_to_process: root_dir = Path.cwd() @@ -430,44 +216,44 @@ def main(): potential_root = filepath.parent else: potential_root = (Path.cwd() / filepath).parent - + gitignore_path = potential_root / ".gitignore" if gitignore_path.exists(): root_dir = potential_root break - + gitignore_matcher = GitignoreMatcher(root_dir) if args.verbose: print_color(f"Using .gitignore from: {root_dir}", Colors.CYAN) - - + processed_files = [] for filepath in files_to_process: if gitignore_matcher and gitignore_matcher.should_ignore(filepath): continue processed_files.append(filepath) - + if args.verbose and gitignore_matcher: skipped = len(files_to_process) - len(processed_files) if skipped > 0: print_color(f"Skipping {skipped} files due to .gitignore", Colors.DIM) - + if not processed_files: if not args.quiet: print_color("No files to process after applying .gitignore", Colors.YELLOW) return - - - from tqdm import tqdm - - if args.progress and not args.interactive: - iterator = tqdm(processed_files, desc="Processing", unit="file", disable=args.quiet) - else: + + try: + from tqdm import tqdm + if args.progress and not args.interactive: + iterator = tqdm(processed_files, desc="Processing", unit="file", disable=args.quiet) + else: + iterator = processed_files + except ImportError: iterator = processed_files - + for filepath in iterator: fixer.process_file(filepath, args, gitignore_matcher) - + fixer.print_stats(args)