Coverage for src/pylint_sort_functions/cli.py: 100%

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

2

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

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

5

6 The entry point is configured in pyproject.toml and maps the 'pylint-sort-functions'

7 command to the main() function in this module. This provides auto-fix functionality

8 independent of the PyLint plugin integration.

9

10 Usage 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.

19 import argparse

20 import sys

21 from pathlib import Path

22 from typing import List, Optional

23

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

25 from astroid import nodes

26

27 from pylint_sort_functions import auto_fix, utils

28 from pylint_sort_functions.auto_fix import AutoFixConfig # Class - direct import OK

29 from pylint_sort_functions.privacy_fixer import ( # Classes - direct import OK

30 PrivacyFixer,

31 RenameCandidate,

32)

33

34# Public functions

35

36

37 def 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

178 def _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

287 def _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

329 def _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

362 def _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

425 def _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

450 def _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

470 def _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

500 def _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

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

570 sys.exit(main())