Coverage for src/pylint_sort_functions/cli.py: 100%
140 statements
« prev ^ index » next coverage.py v7.10.1, created at 2025-08-12 16:06 +0200
« prev ^ index » next coverage.py v7.10.1, created at 2025-08-12 16:06 +0200
1"""Command-line interface for pylint-sort-functions auto-fix.
3This module provides the standalone CLI tool that users invoke via:
4 $ pylint-sort-functions [options] <paths>
6The entry point is configured in pyproject.toml and maps the 'pylint-sort-functions'
7command to the main() function in this module. This provides auto-fix functionality
8independent of the PyLint plugin integration.
10Usage examples:
11 $ pylint-sort-functions --fix src/
12 $ pylint-sort-functions --dry-run --verbose myproject/
13 $ pylint-sort-functions --fix --no-backup --ignore-decorators "@app.route" src/
14"""
16# Using argparse (stdlib) instead of click to maintain zero external dependencies.
17# This lightweight approach is sufficient for our flat argument structure.
18# Future: If subcommands are added, consider migrating to click for better UX.
19import argparse
20import sys
21from pathlib import Path
22from typing import List, Optional
24import astroid # type: ignore[import-untyped]
25from astroid import nodes
27from pylint_sort_functions import auto_fix, utils
28from pylint_sort_functions.auto_fix import AutoFixConfig # Class - direct import OK
29from pylint_sort_functions.privacy_fixer import ( # Classes - direct import OK
30 PrivacyFixer,
31 RenameCandidate,
32)
34# Public functions
37def main() -> int: # pylint: disable=too-many-return-statements,too-many-branches,too-many-locals,too-many-statements
38 """Main CLI entry point for pylint-sort-functions tool.
40 Provides a complete workflow for auto-fixing function and method sorting:
41 1. Parse and validate command-line arguments
42 2. Resolve and validate input paths (files/directories)
43 3. Discover Python files recursively in directories
44 4. Configure auto-fix settings from CLI arguments
45 5. Process files with function/method sorting and comment preservation
46 6. Report results with optional verbose output
48 The tool operates in different modes:
49 - Check-only: Default mode, shows help and exits
50 - Dry-run: Shows what would be changed without modifying files
51 - Fix: Actually modifies files with optional backup creation
53 Exit codes:
54 - 0: Success (files processed successfully, or check-only mode)
55 - 1: Error (invalid paths, processing failures, user interruption)
57 Error handling:
58 - Provides user-friendly error messages instead of stack traces
59 - Handles filesystem errors, permission issues, and processing failures
60 - Graceful handling of keyboard interruption (Ctrl+C)
62 Side effects:
63 - May modify Python files when --fix is used
64 - May create .bak backup files unless --no-backup is specified
65 - Outputs progress and results to stdout
67 :returns: Exit code (0 for success, 1 for error)
68 :rtype: int
69 """
70 parser = argparse.ArgumentParser(
71 prog="pylint-sort-functions",
72 description="Auto-fix function and method sorting in Python files",
73 )
74 _add_parser_arguments(parser)
76 args = parser.parse_args()
78 # Validate arguments
79 if (
80 not args.fix
81 and not args.dry_run
82 and not args.fix_privacy
83 and not args.privacy_dry_run
84 ):
85 print(
86 "Note: Running in check-only mode. Use --fix, --dry-run, "
87 "--fix-privacy, or --privacy-dry-run to make changes."
88 )
89 print("Use 'pylint-sort-functions --help' for more options.")
90 return 0
92 # Check for conflicting privacy options
93 if args.fix_privacy and args.privacy_dry_run:
94 print("Error: Cannot use both --fix-privacy and --privacy-dry-run together.")
95 return 1
97 # Convert paths and find Python files
98 try:
99 paths = [Path(p).resolve() for p in args.paths]
100 for path in paths:
101 if not path.exists():
102 print(f"Error: Path does not exist: {path}")
103 return 1
105 python_files = _find_python_files_from_paths(paths)
106 if not python_files:
107 print("No Python files found in the specified paths.")
108 return 0
110 # Catch broad exceptions for CLI robustness - path operations can fail in
111 # many OS-specific ways, and we want clean error messages not stacktraces
112 except Exception as e: # pragma: no cover # pylint: disable=broad-exception-caught
113 print(f"Error processing paths: {e}")
114 return 1
116 # Configure auto-fix
117 config = AutoFixConfig(
118 dry_run=args.dry_run,
119 backup=not args.no_backup,
120 ignore_decorators=args.ignore_decorators or [],
121 add_section_headers=args.add_section_headers,
122 public_header=args.public_header,
123 private_header=args.private_header,
124 public_method_header=args.public_method_header,
125 private_method_header=args.private_method_header,
126 additional_section_patterns=args.additional_section_patterns,
127 section_header_case_sensitive=args.section_headers_case_sensitive,
128 )
130 if args.verbose: # pragma: no cover
131 print(f"Processing {len(python_files)} Python files...")
132 if config.ignore_decorators:
133 print(f"Ignoring decorators: {', '.join(config.ignore_decorators)}")
134 if config.add_section_headers:
135 print("Section headers enabled:")
136 print(f" Public functions: '{config.public_header}'")
137 print(f" Private functions: '{config.private_header}'")
138 print(f" Public methods: '{config.public_method_header}'")
139 print(f" Private methods: '{config.private_method_header}'")
140 if config.additional_section_patterns:
141 patterns = config.additional_section_patterns
142 print(f" Additional detection patterns: {patterns}")
143 if config.section_header_case_sensitive:
144 print(" Case-sensitive detection enabled")
146 # Process files based on mode
147 try:
148 # Privacy fixing mode
149 if args.fix_privacy or args.privacy_dry_run:
150 return _handle_privacy_fixing(args, python_files, paths)
152 # Regular sorting mode
153 files_processed, files_modified = auto_fix.sort_python_files(
154 python_files, config
155 )
157 if args.verbose or files_modified > 0: # pragma: no cover
158 if config.dry_run:
159 print(f"Would modify {files_modified} of {files_processed} files")
160 else:
161 print(f"Modified {files_modified} of {files_processed} files")
162 if config.backup and files_modified > 0:
163 print("Backup files created with .bak extension")
165 return 0
167 except KeyboardInterrupt:
168 print("\nOperation cancelled by user.")
169 return 1
170 except Exception as e: # pylint: disable=broad-exception-caught
171 print(f"Error during processing: {e}")
172 return 1
175# Private functions
178def _add_parser_arguments(parser: argparse.ArgumentParser) -> None:
179 """Configure CLI argument parser with all supported options.
181 :param parser: The argument parser to configure
182 :type parser: argparse.ArgumentParser
183 """
184 parser.add_argument(
185 "paths", nargs="+", type=Path, help="Python files or directories to process"
186 )
188 parser.add_argument(
189 "--fix",
190 action="store_true",
191 help="Apply auto-fix to sort functions (default: check only)",
192 )
194 parser.add_argument(
195 "--dry-run",
196 action="store_true",
197 help="Show what would be changed without modifying files",
198 )
200 parser.add_argument(
201 "--no-backup",
202 action="store_true",
203 help="Do not create backup files (.bak) when fixing",
204 )
206 parser.add_argument(
207 "--ignore-decorators",
208 action="append",
209 metavar="PATTERN",
210 help='Decorator patterns to ignore (e.g., "@app.route" "@*.command"). '
211 + "Can be used multiple times.",
212 )
214 # Section header options
215 parser.add_argument(
216 "--add-section-headers",
217 action="store_true",
218 help="Add section header comments (e.g., '# Public functions') during sorting",
219 )
221 parser.add_argument(
222 "--public-header",
223 default="# Public functions",
224 metavar="TEXT",
225 help="Header text for public functions (default: '# Public functions')",
226 )
228 parser.add_argument(
229 "--private-header",
230 default="# Private functions",
231 metavar="TEXT",
232 help="Header text for private functions (default: '# Private functions')",
233 )
235 parser.add_argument(
236 "--public-method-header",
237 default="# Public methods",
238 metavar="TEXT",
239 help="Header text for public methods (default: '# Public methods')",
240 )
242 parser.add_argument(
243 "--private-method-header",
244 default="# Private methods",
245 metavar="TEXT",
246 help="Header text for private methods (default: '# Private methods')",
247 )
249 # Section header detection options
250 parser.add_argument(
251 "--additional-section-patterns",
252 action="append",
253 metavar="PATTERN",
254 help="Additional patterns to detect as section headers "
255 + "(e.g., '=== API ===' or '--- Helpers ---'). Can be used multiple times.",
256 )
258 parser.add_argument(
259 "--section-headers-case-sensitive",
260 action="store_true",
261 help="Make section header detection case-sensitive (default: case-insensitive)",
262 )
264 parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
266 # Privacy fixer options
267 parser.add_argument(
268 "--fix-privacy",
269 action="store_true",
270 help="Automatically rename functions that should be private "
271 "(adds underscore prefix)",
272 )
274 parser.add_argument(
275 "--privacy-dry-run",
276 action="store_true",
277 help="Show functions that would be renamed to private (standalone option)",
278 )
280 parser.add_argument(
281 "--auto-sort",
282 action="store_true",
283 help="Automatically apply function sorting after privacy fixes",
284 )
287def _analyze_files_for_privacy(
288 python_files: List[Path],
289 privacy_fixer: PrivacyFixer,
290 project_root: Path,
291 verbose: bool = False,
292) -> List[RenameCandidate]:
293 """Analyze files and return privacy rename candidates.
295 :param python_files: List of Python files to analyze
296 :param privacy_fixer: PrivacyFixer instance to use for analysis
297 :param project_root: Root directory of the project
298 :param verbose: Whether to print verbose output
299 :returns: List of rename candidates found across all files
300 """
301 all_candidates = []
303 for file_path in python_files:
304 try:
305 # Parse the file
306 with open(file_path, "r", encoding="utf-8") as f:
307 content = f.read()
308 module = astroid.parse(content, module_name=str(file_path))
310 # Find functions that should be private
311 functions = utils.get_functions_from_node(module)
312 for func in functions:
313 candidate = _create_rename_candidate(
314 func, file_path, privacy_fixer, project_root
315 )
316 if candidate is not None:
317 all_candidates.append(candidate)
319 except (
320 Exception
321 ) as e: # pragma: no cover # pylint: disable=broad-exception-caught
322 if verbose:
323 print(f"Warning: Could not analyze {file_path}: {e}")
324 continue
326 return all_candidates
329def _apply_integrated_sorting(
330 args: argparse.Namespace, python_files: List[Path]
331) -> None:
332 """Apply function sorting after privacy fixes.
334 :param args: Parsed command-line arguments containing configuration
335 :param python_files: List of Python files to sort
336 """
337 # Create sorting configuration from CLI args
338 config = AutoFixConfig(
339 dry_run=args.privacy_dry_run, # Use privacy dry-run mode for sorting too
340 backup=not args.no_backup,
341 ignore_decorators=args.ignore_decorators or [],
342 add_section_headers=args.add_section_headers,
343 public_header=args.public_header,
344 private_header=args.private_header,
345 public_method_header=args.public_method_header,
346 private_method_header=args.private_method_header,
347 additional_section_patterns=args.additional_section_patterns,
348 section_header_case_sensitive=args.section_headers_case_sensitive,
349 )
351 # Apply sorting
352 files_processed, files_modified = auto_fix.sort_python_files(python_files, config)
354 if config.dry_run:
355 print(f"Would sort {files_modified} of {files_processed} files")
356 else:
357 print(f"Sorted {files_modified} of {files_processed} files")
358 if config.backup and files_modified > 0: # pragma: no cover
359 print("Additional backup files created for sorting changes")
362def _create_rename_candidate(
363 func: nodes.FunctionDef,
364 file_path: Path,
365 privacy_fixer: PrivacyFixer,
366 project_root: Path,
367) -> Optional[RenameCandidate]:
368 """Create and validate a single rename candidate.
370 :param func: Function node to analyze
371 :param file_path: Path to file containing the function
372 :param privacy_fixer: PrivacyFixer instance for validation
373 :param project_root: Root directory of the project
374 :returns: Validated rename candidate or None if not suitable
375 """
376 # Use default public patterns plus common API patterns
377 public_patterns = {
378 "main",
379 "run",
380 "execute",
381 "start",
382 "stop",
383 "setup",
384 "teardown",
385 "public_api",
386 }
388 if not utils.should_function_be_private(
389 func, file_path, project_root, public_patterns
390 ):
391 return None
393 # Parse the module to get references
394 with open(file_path, "r", encoding="utf-8") as f:
395 content = f.read()
396 module = astroid.parse(content, module_name=str(file_path))
398 # Find all references to this function
399 references = privacy_fixer.find_function_references(func.name, module)
401 # Create initial candidate
402 candidate = RenameCandidate(
403 function_node=func,
404 old_name=func.name,
405 new_name=f"_{func.name}",
406 references=references,
407 test_references=[], # Will be populated if needed
408 is_safe=True, # Will be validated next
409 safety_issues=[],
410 )
412 # Validate safety
413 is_safe, issues = privacy_fixer.is_safe_to_rename(candidate)
414 return RenameCandidate(
415 function_node=func,
416 old_name=func.name,
417 new_name=f"_{func.name}",
418 references=references,
419 test_references=[], # Will be populated if needed
420 is_safe=is_safe,
421 safety_issues=issues,
422 )
425def _find_project_root(start_path: Path) -> Path:
426 """Find the project root by looking for common project markers.
428 :param start_path: Starting path to search from
429 :type start_path: Path
430 :returns: Project root path
431 :rtype: Path
432 """
433 current = start_path.resolve()
434 if current.is_file():
435 current = current.parent
437 # Look for common project root indicators
438 markers = ["pyproject.toml", "setup.py", "setup.cfg", ".git", "requirements.txt"]
440 while current != current.parent:
441 for marker in markers:
442 if (current / marker).exists():
443 return current
444 current = current.parent
446 # Fallback to the original path's parent
447 return start_path.parent if start_path.is_file() else start_path
450def _find_python_files_from_paths(paths: List[Path]) -> List[Path]:
451 """Find all Python files in the given paths.
453 :param paths: List of file or directory paths
454 :type paths: List[Path]
455 :returns: List of Python file paths
456 :rtype: List[Path]
457 """
458 python_files = []
460 for path in paths:
461 if path.is_file() and path.suffix == ".py":
462 python_files.append(path)
463 elif path.is_dir():
464 # Recursively find Python files
465 python_files.extend(path.rglob("*.py"))
467 return python_files
470def _handle_privacy_fixing(
471 args: argparse.Namespace, python_files: List[Path], paths: List[Path]
472) -> int:
473 """Handle privacy fixing workflow.
475 :param args: Parsed command-line arguments
476 :param python_files: List of Python files to process
477 :param paths: List of original paths provided
478 :returns: Exit code
479 """
480 if args.verbose: # pragma: no cover
481 print("\n=== Privacy Fixing Mode ===")
482 print(f"Analyzing {len(python_files)} Python files for privacy issues...")
484 privacy_fixer = PrivacyFixer(
485 dry_run=args.privacy_dry_run, backup=not args.no_backup
486 )
487 project_root = _find_project_root(paths[0])
489 # Analyze all files and collect rename candidates
490 all_candidates = _analyze_files_for_privacy(
491 python_files, privacy_fixer, project_root, args.verbose
492 )
494 # Process results and apply fixes if requested
495 return _process_privacy_results(
496 all_candidates, args, python_files, paths, privacy_fixer
497 )
500def _process_privacy_results( # pylint: disable=too-many-branches
501 all_candidates: List[RenameCandidate],
502 args: argparse.Namespace,
503 python_files: List[Path],
504 paths: List[Path],
505 privacy_fixer: PrivacyFixer,
506) -> int:
507 """Handle privacy analysis results and apply fixes if requested.
509 :param all_candidates: List of rename candidates found
510 :param args: Parsed command-line arguments
511 :param python_files: List of Python files being processed
512 :param paths: List of original paths provided
513 :param privacy_fixer: PrivacyFixer instance for applying renames
514 :returns: Exit code
515 """
516 if all_candidates:
517 report = privacy_fixer.generate_report(all_candidates)
518 print(report)
520 # Apply renames if not in dry-run mode
521 if args.fix_privacy:
522 # Determine project root from the provided paths
523 test_project_root: Optional[Path] = None
524 if paths:
525 # Use the first path as project root, or its parent if it's a file
526 first_path = paths[0]
527 if first_path.is_dir():
528 test_project_root = first_path
529 else:
530 test_project_root = first_path.parent
532 result = privacy_fixer.apply_renames(all_candidates, test_project_root)
533 print(f"\nRenamed {result['renamed']} functions.")
534 if result["skipped"] > 0: # pragma: no cover
535 print(f"Skipped {result['skipped']} unsafe renames.")
536 if result.get("errors"): # pragma: no cover
537 for error in result["errors"]:
538 print(f"Error: {error}")
540 # Report test file updates
541 if "test_files_updated" in result:
542 if result["test_files_updated"] > 0:
543 print(f"Updated {result['test_files_updated']} test files.")
544 if result.get("test_file_errors"):
545 print("\nTest file update errors:")
546 for error in result["test_file_errors"]:
547 print(f" {error}")
549 # Apply automatic sorting if requested
550 if args.auto_sort and result["renamed"] > 0:
551 print("\n=== Applying Automatic Sorting ===")
552 _apply_integrated_sorting(args, python_files)
553 # For dry-run mode with auto-sort, show what sorting would do
554 if args.privacy_dry_run and args.auto_sort and all_candidates:
555 print("\n=== Auto-Sort Preview ===")
556 _apply_integrated_sorting(args, python_files)
557 else: # pragma: no cover
558 print("No functions found that need privacy fixes.")
560 # For dry-run mode with auto-sort on files with no privacy issues,
561 # still show sorting preview
562 if args.privacy_dry_run and args.auto_sort:
563 print("\n=== Auto-Sort Preview ===")
564 _apply_integrated_sorting(args, python_files)
566 return 0
569if __name__ == "__main__": # pragma: no cover
570 sys.exit(main())