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

1"""Command-line interface for pylint-sort-functions auto-fix. 

2 

3This module provides the standalone CLI tool that users invoke via: 

4 $ pylint-sort-functions [options] <paths> 

5 

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. 

9 

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""" 

15 

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 

23 

24import astroid # type: ignore[import-untyped] 

25from astroid import nodes 

26 

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) 

33 

34# Public functions 

35 

36 

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. 

39 

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 

47 

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 

52 

53 Exit codes: 

54 - 0: Success (files processed successfully, or check-only mode) 

55 - 1: Error (invalid paths, processing failures, user interruption) 

56 

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) 

61 

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 

66 

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) 

75 

76 args = parser.parse_args() 

77 

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 

91 

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 

96 

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 

104 

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 

109 

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 

115 

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 ) 

129 

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") 

145 

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) 

151 

152 # Regular sorting mode 

153 files_processed, files_modified = auto_fix.sort_python_files( 

154 python_files, config 

155 ) 

156 

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") 

164 

165 return 0 

166 

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 

173 

174 

175# Private functions 

176 

177 

178def _add_parser_arguments(parser: argparse.ArgumentParser) -> None: 

179 """Configure CLI argument parser with all supported options. 

180 

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 ) 

187 

188 parser.add_argument( 

189 "--fix", 

190 action="store_true", 

191 help="Apply auto-fix to sort functions (default: check only)", 

192 ) 

193 

194 parser.add_argument( 

195 "--dry-run", 

196 action="store_true", 

197 help="Show what would be changed without modifying files", 

198 ) 

199 

200 parser.add_argument( 

201 "--no-backup", 

202 action="store_true", 

203 help="Do not create backup files (.bak) when fixing", 

204 ) 

205 

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 ) 

213 

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 ) 

220 

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 ) 

227 

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 ) 

234 

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 ) 

241 

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 ) 

248 

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 ) 

257 

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 ) 

263 

264 parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") 

265 

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 ) 

273 

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 ) 

279 

280 parser.add_argument( 

281 "--auto-sort", 

282 action="store_true", 

283 help="Automatically apply function sorting after privacy fixes", 

284 ) 

285 

286 

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. 

294 

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 = [] 

302 

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)) 

309 

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) 

318 

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 

325 

326 return all_candidates 

327 

328 

329def _apply_integrated_sorting( 

330 args: argparse.Namespace, python_files: List[Path] 

331) -> None: 

332 """Apply function sorting after privacy fixes. 

333 

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 ) 

350 

351 # Apply sorting 

352 files_processed, files_modified = auto_fix.sort_python_files(python_files, config) 

353 

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") 

360 

361 

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. 

369 

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 } 

387 

388 if not utils.should_function_be_private( 

389 func, file_path, project_root, public_patterns 

390 ): 

391 return None 

392 

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)) 

397 

398 # Find all references to this function 

399 references = privacy_fixer.find_function_references(func.name, module) 

400 

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 ) 

411 

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 ) 

423 

424 

425def _find_project_root(start_path: Path) -> Path: 

426 """Find the project root by looking for common project markers. 

427 

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 

436 

437 # Look for common project root indicators 

438 markers = ["pyproject.toml", "setup.py", "setup.cfg", ".git", "requirements.txt"] 

439 

440 while current != current.parent: 

441 for marker in markers: 

442 if (current / marker).exists(): 

443 return current 

444 current = current.parent 

445 

446 # Fallback to the original path's parent 

447 return start_path.parent if start_path.is_file() else start_path 

448 

449 

450def _find_python_files_from_paths(paths: List[Path]) -> List[Path]: 

451 """Find all Python files in the given paths. 

452 

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 = [] 

459 

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")) 

466 

467 return python_files 

468 

469 

470def _handle_privacy_fixing( 

471 args: argparse.Namespace, python_files: List[Path], paths: List[Path] 

472) -> int: 

473 """Handle privacy fixing workflow. 

474 

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...") 

483 

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]) 

488 

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 ) 

493 

494 # Process results and apply fixes if requested 

495 return _process_privacy_results( 

496 all_candidates, args, python_files, paths, privacy_fixer 

497 ) 

498 

499 

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. 

508 

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) 

519 

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 

531 

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}") 

539 

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}") 

548 

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.") 

559 

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) 

565 

566 return 0 

567 

568 

569if __name__ == "__main__": # pragma: no cover 

570 sys.exit(main())