Coverage for .tox/py313/lib/python3.13/site-packages/pylint_sort_functions/auto_fix.py: 100%

1"""Auto-fix functionality for sorting functions and methods."""

2

3 import shutil

4 from dataclasses import dataclass

5 from pathlib import Path

6 from typing import List, Optional, Tuple

7

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

9 from astroid import nodes

10

11 from pylint_sort_functions import utils

12

13

14@dataclass

15 class FunctionSpan:

16 """Represents a function with its complete text span in the source file."""

17

18 node: nodes.FunctionDef

19 start_line: int

20 end_line: int

21 text: str # Complete source text from start_line to end_line (inclusive)

22 name: str

23

24

25@dataclass

26 class AutoFixConfig:

27 """Configuration for the automatic function sorting tool.

28

29 Controls how the auto-fix feature behaves when reordering functions

30 and methods in Python source files.

31

32 Note: Comment preservation is always enabled as it's essential for

33 maintaining code intent and documentation during reorganization.

34 """

35

36 dry_run: bool = False # Show what would be changed without modifying files

37 backup: bool = True # Create .bak files before making changes

38 ignore_decorators: Optional[List[str]] = (

39 None # Decorator patterns to exclude from sorting

40 )

41

42

43# Note: This class intentionally has only one public method as it encapsulates

44# the configuration state and provides a clean interface for file processing.

45 class FunctionSorter: # pylint: disable=too-few-public-methods

46 """Main class for auto-fixing function sorting.

47

48 This class provides the core functionality for automatically reordering

49 functions and methods in Python source files to comply with sorting rules.

50

51 Usage:

52 Used by the CLI tool (cli.py) and can be used programmatically:

53

54 config = AutoFixConfig(dry_run=True, backup=True)

55 sorter = FunctionSorter(config)

56 was_modified = sorter.sort_file(Path("my_file.py"))

57 """

58

59 def __init__(self, config: AutoFixConfig):

60 """Initialize the function sorter.

61

62 :param config: Configuration for auto-fix behavior

63 :type config: AutoFixConfig

64 """

65 self.config = config

66 if self.config.ignore_decorators is None:

67 self.config.ignore_decorators = []

68

69 def sort_file(self, file_path: Path) -> bool:

70 """Auto-sort functions in a Python file.

71

72 :param file_path: Path to the Python file to sort

73 :type file_path: Path

74 :returns: True if file was modified, False otherwise

75 :rtype: bool

76 """

77 try:

78 # Read the original file

79 original_content = file_path.read_text(encoding="utf-8")

80

81 # Check if file needs sorting

82 if not self._file_needs_sorting(original_content):

83 return False

84

85 # Extract and sort functions

86 new_content = self._sort_functions_in_content(original_content)

87

88 if new_content == original_content: # pragma: no cover

89 return False

90

91 if self.config.dry_run:

92 print(f"Would modify: {file_path}")

93 return True

94

95 # Create backup if requested

96 if self.config.backup:

97 backup_path = file_path.with_suffix(f"{file_path.suffix}.bak")

98 shutil.copy2(file_path, backup_path)

99

100 # Write the sorted content

101 file_path.write_text(new_content, encoding="utf-8")

102 return True

103

104 # Broad exception catch ensures tool never crashes when modifying user files

105 except (

106 Exception

107 ) as e: # pragma: no cover # pylint: disable=broad-exception-caught

108 print(f"Error processing {file_path}: {e}")

109 return False

110

111 def _extract_function_spans(

112 self, functions: List[nodes.FunctionDef], lines: List[str]

113 ) -> List[FunctionSpan]:

114 """Extract function text spans from the source.

115

116 :param functions: List of function nodes

117 :type functions: List[nodes.FunctionDef]

118 :param lines: Source file lines

119 :type lines: List[str]

120 :returns: List of function spans with text

121 :rtype: List[FunctionSpan]

122 """

123 spans = []

124

125 # First pass: determine where each function (including comments) starts

126 function_boundaries = []

127 for func in functions:

128 start_line = func.lineno - 1 # Convert to 0-based indexing

129

130 # Include decorators in the span

131 actual_start = start_line

132 if hasattr(func, "decorators") and func.decorators:

133 actual_start = func.decorators.lineno - 1

134

135 # Include comments above the function/decorators

136 comment_start = self._find_comments_above_function(lines, actual_start)

137 function_boundaries.append((func, comment_start))

138

139 # Second pass: create spans using the boundaries

140 for i, (func, comment_start) in enumerate(function_boundaries):

141 # Find the end line (start of next function or end of file)

142 if i + 1 < len(function_boundaries):

143 # End where the next function's comments start

144 end_line = function_boundaries[i + 1][1]

145 else:

146 # Last function, use end of file

147 end_line = len(lines)

148

149 # Extract the text including comments

150 text = "".join(lines[comment_start: end_line])

151

152 spans.append(

153 FunctionSpan(

154 node=func,

155 start_line=comment_start,

156 end_line=end_line,

157 text=text,

158 name=func.name,

159 )

160 )

161

162 return spans

163

164 def _extract_method_spans(

165 self,

166 methods: List[nodes.FunctionDef],

167 lines: List[str],

168 class_node: nodes.ClassDef,

169 ) -> List[FunctionSpan]:

170 """Extract method text spans from a class.

171

172 :param methods: List of method nodes from the class

173 :type methods: List[nodes.FunctionDef]

174 :param lines: Source file lines

175 :type lines: List[str]

176 :param class_node: The class containing these methods

177 :type class_node: nodes.ClassDef

178 :returns: List of method spans with text

179 :rtype: List[FunctionSpan]

180 """

181 spans = []

182

183 # First pass: determine where each method (including comments) starts

184 method_boundaries = []

185 for method in methods:

186 start_line = method.lineno - 1 # Convert to 0-based indexing

187

188 # Include decorators in the span

189 actual_start = start_line

190 if hasattr(method, "decorators") and method.decorators:

191 actual_start = method.decorators.lineno - 1

192

193 # Include comments above the method/decorators

194 comment_start = self._find_comments_above_function(lines, actual_start)

195 method_boundaries.append((method, comment_start))

196

197 # Second pass: create spans using the boundaries

198 for i, (method, comment_start) in enumerate(method_boundaries):

199 # Find the end line (start of next method or end of class)

200 if i + 1 < len(method_boundaries):

201 # End where the next method's comments start

202 end_line = method_boundaries[i + 1][1]

203 else:

204 # Last method in class, find end of class

205 end_line = (

206 class_node.end_lineno

207 if hasattr(class_node, "end_lineno")

208 else len(lines)

209 )

210

211 # Extract the text including comments

212 text = "".join(lines[comment_start: end_line])

213

214 spans.append(

215 FunctionSpan(

216 node=method,

217 start_line=comment_start,

218 end_line=end_line,

219 text=text,

220 name=method.name,

221 )

222 )

223

224 return spans

225

226 def _file_needs_sorting(self, content: str) -> bool:

227 """Check if a file needs function sorting.

228

229 :param content: File content as string

230 :type content: str

231 :returns: True if file needs sorting

232 :rtype: bool

233 """

234 try:

235 # Parse with astroid for consistency with the checker

236 module = astroid.parse(content)

237

238 # Check module-level functions

239 functions = utils.get_functions_from_node(module)

240 if functions and not utils.are_functions_sorted_with_exclusions(

241 functions, self.config.ignore_decorators

242 ):

243 return True

244

245 # Check class methods

246 for node in module.body:

247 if isinstance(node, nodes.ClassDef):

248 methods = utils.get_methods_from_class(node)

249 if methods and not utils.are_methods_sorted_with_exclusions(

250 methods, self.config.ignore_decorators

251 ):

252 return True

253

254 return False

255

256 except Exception: # pylint: disable=broad-exception-caught

257 return False

258

259 def _find_comments_above_function(

260 self, lines: List[str], function_start_line: int

261 ) -> int:

262 """Find comments that belong to a function and return the start line.

263

264 Scans backwards from the function definition to find associated comments.

265

266 :param lines: Source file lines

267 :type lines: List[str]

268 :param function_start_line: The line where the function starts (0-based)

269 :type function_start_line: int

270 :returns: The line number where comments start, or function_start_line

271 :rtype: int

272 """

273 comment_start_line = function_start_line

274

275 # Scan backwards from the function start to find comments

276 current_line = function_start_line - 1

277

278 while current_line >= 0:

279 line = lines[current_line].strip()

280

281 # If we find a comment line, this could be part of the function's comments

282 if line.startswith("#"):

283 comment_start_line = current_line

284 current_line -= 1

285 continue

286

287 # If we find an empty line, continue scanning (comments might be separated)

288 if line == "":

289 current_line -= 1

290 continue

291

292 # If we find any other content, stop scanning

293 break

294

295 return comment_start_line

296

297 def _reconstruct_class_with_sorted_methods(

298 self,

299 content: str,

300 original_spans: List[FunctionSpan],

301 sorted_spans: List[FunctionSpan],

302 ) -> str:

303 """Reconstruct class content with sorted methods.

304

305 :param content: Original file content

306 :type content: str

307 :param original_spans: Original method spans in order of appearance

308 :type original_spans: List[FunctionSpan]

309 :param sorted_spans: Method spans in sorted order

310 :type sorted_spans: List[FunctionSpan]

311 :returns: Reconstructed content with sorted methods

312 :rtype: str

313 """

314 if not original_spans: # pragma: no cover

315 return content

316

317 # Find the region that contains all methods within the class

318 first_method_start = min(span.start_line for span in original_spans)

319 last_method_end = max(span.end_line for span in original_spans)

320

321 # Split content into lines for manipulation

322 content_lines = content.splitlines(keepends=True)

323

324 # Build new content

325 new_lines = []

326

327 # Add everything before the first method

328 new_lines.extend(content_lines[:first_method_start])

329

330 # Add sorted methods

331 for span in sorted_spans:

332 # Method text already includes proper spacing

333 new_lines.append(span.text)

334

335 # Add everything after the last method

336 if last_method_end < len(content_lines):

337 new_lines.extend(content_lines[last_method_end:])

338

339 return "".join(new_lines)

340

341 def _reconstruct_content_with_sorted_functions(

342 self,

343 original_content: str,

344 original_spans: List[FunctionSpan],

345 sorted_spans: List[FunctionSpan],

346 ) -> str:

347 """Reconstruct file content with sorted functions.

348

349 Strategy:

350 1. Preserve everything before the first function (imports, module docstrings)

351 2. Replace the function block with sorted functions

352 3. Preserve everything after the last function

353 4. Add blank lines between functions if not already present

354

355 This approach ensures non-function content (imports, constants, etc.)

356 remains in its original position while only reordering functions.

357

358 :param original_content: Original file content

359 :type original_content: str

360 :param original_spans: Original function spans in order of appearance

361 :type original_spans: List[FunctionSpan]

362 :param sorted_spans: Function spans in sorted order

363 :type sorted_spans: List[FunctionSpan]

364 :returns: Reconstructed content with sorted functions

365 :rtype: str

366 """

367 if not original_spans: # pragma: no cover

368 return original_content

369

370 lines = original_content.splitlines(keepends=True)

371

372 # Find the region that contains all functions

373 first_func_start = min(span.start_line for span in original_spans)

374 last_func_end = max(span.end_line for span in original_spans)

375

376 # Build new content

377 new_lines = []

378

379 # Add everything before the first function

380 new_lines.extend(lines[:first_func_start])

381

382 # Add sorted functions

383 for i, span in enumerate(sorted_spans):

384 if i > 0:

385 # Add blank line between functions if not already present

386 if not span.text.startswith("\n"):

387 new_lines.append("\n")

388 new_lines.append(span.text)

389

390 # Add everything after the last function

391 if last_func_end < len(lines): # pragma: no cover

392 new_lines.extend(lines[last_func_end:])

393

394 return "".join(new_lines)

395

396 def _sort_class_methods(

397 self, content: str, module: nodes.Module, lines: List[str]

398 ) -> str:

399 """Sort methods within classes.

400

401 :param content: File content

402 :type content: str

403 :param module: Parsed module

404 :type module: nodes.Module

405 :param lines: Content split into lines

406 :type lines: List[str]

407 :returns: Content with sorted class methods

408 :rtype: str

409 """

410 # Find all classes that need method sorting

411 classes_to_sort = []

412 for node in module.body:

413 if isinstance(node, nodes.ClassDef):

414 methods = utils.get_methods_from_class(node)

415 if methods and not utils.are_methods_sorted_with_exclusions(

416 methods, self.config.ignore_decorators

417 ):

418 classes_to_sort.append((node, methods))

419

420 if not classes_to_sort:

421 return content

422

423 # Sort each class's methods

424 for class_node, methods in classes_to_sort:

425 # Extract method spans for this class

426 method_spans = self._extract_method_spans(methods, lines, class_node)

427

428 # Sort the method spans

429 sorted_spans = self._sort_function_spans(method_spans)

430

431 # Reconstruct the class content with sorted methods

432 content = self._reconstruct_class_with_sorted_methods(

433 content, method_spans, sorted_spans

434 )

435

436 return content

437

438 def _sort_function_spans(self, spans: List[FunctionSpan]) -> List[FunctionSpan]:

439 """Sort function spans according to the plugin rules.

440

441 :param spans: List of function spans to sort

442 :type spans: List[FunctionSpan]

443 :returns: Sorted list of function spans

444 :rtype: List[FunctionSpan]

445 """

446 # Separate functions based on exclusions and visibility

447 excluded = []

448 sortable_public = []

449 sortable_private = []

450

451 for span in spans:

452 if utils.function_has_excluded_decorator(

453 span.node, self.config.ignore_decorators or []

454 ):

455 excluded.append(span)

456 elif utils.is_private_function(span.node):

457 sortable_private.append(span)

458 else:

459 sortable_public.append(span)

460

461 # Sort the sortable functions alphabetically

462 sortable_public.sort(key=lambda s: s.name)

463 sortable_private.sort(key=lambda s: s.name)

464

465 # Reconstruct the order: sortable public + sortable private + excluded

466 # For now, use a simple approach: public sorted + private sorted + excluded

467 # Future enhancement: Preserve relative positions of excluded functions

468 return sortable_public + sortable_private + excluded

469

470 def _sort_functions_in_content(self, content: str) -> str:

471 """Sort functions in file content and return new content.

472

473 :param content: Original file content

474 :type content: str

475 :returns: Content with sorted functions

476 :rtype: str

477 """

478 try:

479 module = astroid.parse(content)

480 lines = content.splitlines(keepends=True)

481

482 # Process module-level functions

483 content = self._sort_module_functions(content, module, lines)

484

485 # Process class methods

486 content = self._sort_class_methods(content, module, lines)

487

488 return content

489

490 except (

491 Exception

492 ) as e: # pragma: no cover # pylint: disable=broad-exception-caught

493 print(f"Error sorting content: {e}")

494 return content

495

496 def _sort_module_functions(

497 self, content: str, module: nodes.Module, lines: List[str]

498 ) -> str:

499 """Sort module-level functions.

500

501 :param content: File content

502 :type content: str

503 :param module: Parsed module

504 :type module: nodes.Module

505 :param lines: Content split into lines

506 :type lines: List[str]

507 :returns: Content with sorted module functions

508 :rtype: str

509 """

510 functions = utils.get_functions_from_node(module)

511 if not functions: # pragma: no cover

512 return content

513

514 # Check if sorting is needed

515 if utils.are_functions_sorted_with_exclusions( # pragma: no cover

516 functions, self.config.ignore_decorators

517 ):

518 return content

519

520 # Extract function spans

521 function_spans = self._extract_function_spans(functions, lines)

522

523 # Sort the functions

524 sorted_spans = self._sort_function_spans(function_spans)

525

526 # Reconstruct content

527 return self._reconstruct_content_with_sorted_functions(

528 content, function_spans, sorted_spans

529 )

530

531

532# Public API function for sorting a single file

533 def sort_python_file(file_path: Path, config: AutoFixConfig) -> bool: # pylint: disable=function-should-be-private

534 """Sort functions in a Python file.

535

536 :param file_path: Path to the Python file

537 :type file_path: Path

538 :param config: Auto-fix configuration

539 :type config: AutoFixConfig

540 :returns: True if file was modified

541 :rtype: bool

542 """

543 return _sort_python_file(file_path, config)

544

545

546 def sort_python_files(file_paths: List[Path], config: AutoFixConfig) -> Tuple[int, int]:

547 """Sort functions in multiple Python files.

548

549 :param file_paths: List of Python file paths

550 :type file_paths: List[Path]

551 :param config: Auto-fix configuration

552 :type config: AutoFixConfig

553 :returns: Tuple of (files_processed, files_modified)

554 :rtype: Tuple[int, int]

555 """

556 files_processed = 0

557 files_modified = 0

558

559 for file_path in file_paths:

560 if file_path.suffix == ".py":

561 files_processed += 1

562 if _sort_python_file(file_path, config):

563 files_modified += 1

564

565 return files_processed, files_modified

566

567

568# Private functions

569

570

571 def _sort_python_file(file_path: Path, config: AutoFixConfig) -> bool:

572 """Sort functions in a Python file (private implementation).

573

574 :param file_path: Path to the Python file

575 :type file_path: Path

576 :param config: Auto-fix configuration

577 :type config: AutoFixConfig

578 :returns: True if file was modified

579 :rtype: bool

580 """

581 sorter = FunctionSorter(config)

582 return sorter.sort_file(file_path)