Coverage for src/pylint_sort_functions/auto_fix.py: 100%
393 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"""Auto-fix functionality for sorting functions and methods."""
2# pylint: disable=too-many-lines
4import shutil
5from dataclasses import dataclass
6from pathlib import Path
7from typing import Dict, List, Optional, Tuple
9import astroid # type: ignore[import-untyped]
10from astroid import nodes
12from pylint_sort_functions import utils
13from pylint_sort_functions.utils.categorization import CategoryConfig, categorize_method
16@dataclass
17class FunctionSpan:
18 """Represents a function with its complete text span in the source file."""
20 node: nodes.FunctionDef
21 start_line: int
22 end_line: int
23 text: str # Complete source text from start_line to end_line (inclusive)
24 name: str
27@dataclass
28class AutoFixConfig: # pylint: disable=too-many-instance-attributes
29 """Configuration for the automatic function sorting tool.
31 Controls how the auto-fix feature behaves when reordering functions
32 and methods in Python source files.
34 Note: Comment preservation is always enabled as it's essential for
35 maintaining code intent and documentation during reorganization.
36 """
38 dry_run: bool = False # Show what would be changed without modifying files
39 backup: bool = True # Create .bak files before making changes
40 ignore_decorators: Optional[List[str]] = (
41 None # Decorator patterns to exclude from sorting
42 )
44 # Section header configuration
45 add_section_headers: bool = False # Add section headers during sorting
46 public_header: str = "# Public functions" # Header text for public functions
47 private_header: str = "# Private functions" # Header text for private functions
48 public_method_header: str = "# Public methods" # Header text for public methods
49 private_method_header: str = "# Private methods" # Header text for private methods
51 # Section header detection configuration
52 additional_section_patterns: Optional[List[str]] = (
53 None # Extra patterns to detect as headers
54 )
55 section_header_case_sensitive: bool = False # Case sensitivity for header detection
57 # Multi-category system integration
58 category_config: Optional[CategoryConfig] = None # Use new categorization system
59 enable_multi_category_headers: bool = False # Enable multi-category section headers
62# Note: This class intentionally has only one public method as it encapsulates
63# the configuration state and provides a clean interface for file processing.
64class FunctionSorter: # pylint: disable=too-many-public-methods,too-few-public-methods
65 """Main class for auto-fixing function sorting.
67 This class provides the core functionality for automatically reordering
68 functions and methods in Python source files to comply with sorting rules.
70 Supports both traditional binary public/private sorting and the new
71 multi-category system with flexible section headers.
73 Basic Usage:
74 Used by the CLI tool (cli.py) and can be used programmatically:
76 config = AutoFixConfig(dry_run=True, backup=True)
77 sorter = FunctionSorter(config)
78 was_modified = sorter.sort_file(Path("my_file.py"))
80 Multi-Category Usage:
81 Enhanced functionality with custom categories and section headers:
83 from pylint_sort_functions.utils import CategoryConfig, MethodCategory
85 # Define custom categories
86 category_config = CategoryConfig(
87 enable_categories=True,
88 categories=[
89 MethodCategory(name="test_methods", patterns=["test_*"],
90 section_header="# Test methods"),
91 MethodCategory(name="properties", decorators=["@property"],
92 section_header="# Properties"),
93 MethodCategory(name="public_methods", patterns=["*"],
94 section_header="# Public methods"),
95 MethodCategory(name="private_methods", patterns=["_*"],
96 section_header="# Private methods"),
97 ]
98 )
100 # Configure auto-fix with multi-category support
101 config = AutoFixConfig(
102 add_section_headers=True,
103 enable_multi_category_headers=True,
104 category_config=category_config
105 )
107 sorter = FunctionSorter(config)
108 was_modified = sorter.sort_file(Path("my_file.py"))
109 """
111 # Public methods
113 def __init__(self, config: AutoFixConfig):
114 """Initialize the function sorter.
116 :param config: Configuration for auto-fix behavior
117 :type config: AutoFixConfig
118 """
119 self.config = config
120 if self.config.ignore_decorators is None:
121 self.config.ignore_decorators = []
123 def sort_file(self, file_path: Path) -> bool:
124 """Auto-sort functions in a Python file.
126 :param file_path: Path to the Python file to sort
127 :type file_path: Path
128 :returns: True if file was modified, False otherwise
129 :rtype: bool
130 """
131 try:
132 # Read the original file
133 original_content = file_path.read_text(encoding="utf-8")
135 # Check if file needs sorting
136 if not self._file_needs_sorting(original_content):
137 return False
139 # Extract and sort functions
140 new_content = self._sort_functions_in_content(original_content)
142 if new_content == original_content: # pragma: no cover
143 return False
145 # CRITICAL FIX: Validate syntax after transformation
146 validated_content = self._validate_syntax_and_rollback(
147 file_path, original_content, new_content
148 )
150 # If validation rolled back to original, no changes were made
151 if validated_content == original_content:
152 return False
154 if self.config.dry_run:
155 print(f"Would modify: {file_path}")
156 return True
158 # Create backup if requested
159 if self.config.backup:
160 backup_path = file_path.with_suffix(f"{file_path.suffix}.bak")
161 shutil.copy2(file_path, backup_path)
163 # Write the validated sorted content
164 file_path.write_text(validated_content, encoding="utf-8")
165 return True
167 # Broad exception catch ensures tool never crashes when modifying user files
168 except (
169 Exception
170 ) as e: # pragma: no cover # pylint: disable=broad-exception-caught
171 print(f"Error processing {file_path}: {e}")
172 return False
174 # Private methods
176 def _add_multi_category_section_headers_to_functions(
177 self, sorted_spans: List[FunctionSpan], is_methods: bool = False
178 ) -> List[str]:
179 """Add multi-category section headers to sorted function spans.
181 This enhanced version supports the new categorization system with multiple
182 categories beyond just public/private. Each category gets its own section
183 header based on the CategoryConfig.
185 :param sorted_spans: Function spans in sorted order by category
186 :type sorted_spans: List[FunctionSpan]
187 :param is_methods: True if these are class methods, False for module functions
188 :type is_methods: bool
189 :returns: List of text lines with category headers and functions
190 :rtype: List[str]
191 """
192 if (
193 not self.config.enable_multi_category_headers
194 or not self.config.category_config
195 ):
196 # Fall back to original binary public/private headers
197 return self._add_section_headers_to_functions(sorted_spans, is_methods)
199 if not self.config.add_section_headers:
200 # If section headers are disabled, just return function texts
201 result = []
202 for i, span in enumerate(sorted_spans):
203 result.append(span.text)
204 if i < len(sorted_spans) - 1 and not span.text.endswith("\n\n"):
205 if not span.text.endswith("\n"):
206 result.append("\n")
207 result.append("\n")
208 return result
210 result_lines: list[str] = []
211 current_category = None
213 for span in sorted_spans:
214 # Determine the category for this function/method
215 category = categorize_method(span.node, self.config.category_config)
217 # Add section header if we're entering a new category
218 if current_category != category:
219 # Add blank line before section header (except at the very beginning)
220 if result_lines:
221 result_lines.append("\n")
223 # Find the category definition to get section header text
224 category_def = None
225 for cat in self.config.category_config.categories:
226 if cat.name == category:
227 category_def = cat
228 break
230 # Add section header if category has one defined
231 if category_def and category_def.section_header:
232 result_lines.append(f"{category_def.section_header}\n\n")
233 else:
234 # Fallback to generic header based on category name
235 header_text = category.replace("_", " ").title()
236 result_lines.append(f"# {header_text}\n\n")
238 current_category = category
240 # Add the function text
241 result_lines.append(span.text)
243 return result_lines
245 def _add_section_headers_to_functions( # pylint: disable=too-many-branches
246 self, sorted_spans: List[FunctionSpan], is_methods: bool = False
247 ) -> List[str]:
248 """Add section headers to sorted function spans.
250 Creates a list of lines that includes both section headers and function text,
251 organized with public functions first, then private functions.
253 :param sorted_spans: Function spans in sorted order (public first, then private)
254 :type sorted_spans: List[FunctionSpan]
255 :param is_methods: True if these are class methods, False for module functions
256 :type is_methods: bool
257 :returns: List of text lines with headers and functions
258 :rtype: List[str]
259 """
260 if not self.config.add_section_headers:
261 # If section headers are disabled, just return function texts with spacing
262 result = []
263 for i, span in enumerate(sorted_spans):
264 result.append(span.text)
265 # Ensure proper spacing between functions if not already included
266 if i < len(sorted_spans) - 1 and not span.text.endswith("\n\n"):
267 if not span.text.endswith("\n"):
268 result.append("\n")
269 result.append("\n")
270 return result
272 if not self._has_mixed_visibility_functions(sorted_spans):
273 # Only add headers when both public and private functions exist
274 # Still ensure proper spacing between functions
275 result = []
276 for i, span in enumerate(sorted_spans):
277 result.append(span.text)
278 # Ensure proper spacing between functions if not already included
279 if i < len(sorted_spans) - 1 and not span.text.endswith("\n\n"):
280 if not span.text.endswith("\n"):
281 result.append("\n") # pragma: no cover
282 result.append("\n")
283 return result
285 result_lines: list[str] = []
286 current_visibility = None # Track whether we're in public or private section
288 # Get appropriate header texts based on function type
289 if is_methods:
290 public_header = self.config.public_method_header
291 private_header = self.config.private_method_header
292 else:
293 public_header = self.config.public_header
294 private_header = self.config.private_header
296 for i, span in enumerate(sorted_spans):
297 is_private = utils.is_private_function(span.node)
298 section_visibility = "private" if is_private else "public"
300 # Add section header if we're entering a new section
301 if current_visibility != section_visibility:
302 # Add blank line before section header (except at the very beginning)
303 if result_lines:
304 result_lines.append("\n")
306 # Add appropriate section header
307 if section_visibility == "public":
308 result_lines.append(f"{public_header}\n\n")
309 else:
310 result_lines.append(f"{private_header}\n\n")
312 current_visibility = section_visibility
314 # Add the function text
315 result_lines.append(span.text)
317 return result_lines
319 def _extract_function_spans(
320 self, functions: List[nodes.FunctionDef], lines: List[str], module: nodes.Module
321 ) -> List[FunctionSpan]:
322 """Extract function text spans from the source.
324 :param functions: List of function nodes
325 :type functions: List[nodes.FunctionDef]
326 :param lines: Source file lines
327 :type lines: List[str]
328 :param module: Module containing the functions
329 :type module: nodes.Module
330 :returns: List of function spans with text
331 :rtype: List[FunctionSpan]
332 """
333 spans = []
335 # First pass: determine where each function (including comments) starts
336 function_boundaries = []
337 for func in functions:
338 start_line = func.lineno - 1 # Convert to 0-based indexing
340 # Include decorators in the span
341 actual_start = start_line
342 if hasattr(func, "decorators") and func.decorators:
343 actual_start = func.decorators.lineno - 1
345 # Include comments above the function/decorators
346 comment_start = self._find_comments_above_function(lines, actual_start)
347 function_boundaries.append((func, comment_start))
349 # Second pass: create spans using the boundaries
350 for i, (func, comment_start) in enumerate(function_boundaries):
351 # Find the end line (start of next function or end of file)
352 if i + 1 < len(function_boundaries):
353 # End where the next function's comments start
354 end_line = function_boundaries[i + 1][1]
355 else:
356 # Last function, find the actual end using AST boundary detection
357 end_line = self._find_function_end(lines, func, module)
359 # Extract the text including comments
360 text = "".join(lines[comment_start:end_line])
362 spans.append(
363 FunctionSpan(
364 node=func,
365 start_line=comment_start,
366 end_line=end_line,
367 text=text,
368 name=func.name,
369 )
370 )
372 return spans
374 def _extract_method_spans(
375 self,
376 methods: List[nodes.FunctionDef],
377 lines: List[str],
378 class_node: nodes.ClassDef,
379 ) -> List[FunctionSpan]:
380 """Extract method text spans from a class.
382 :param methods: List of method nodes from the class
383 :type methods: List[nodes.FunctionDef]
384 :param lines: Source file lines
385 :type lines: List[str]
386 :param class_node: The class containing these methods
387 :type class_node: nodes.ClassDef
388 :returns: List of method spans with text
389 :rtype: List[FunctionSpan]
390 """
391 spans = []
393 # First pass: determine where each method (including comments) starts
394 method_boundaries = []
395 for method in methods:
396 start_line = method.lineno - 1 # Convert to 0-based indexing
398 # Include decorators in the span
399 actual_start = start_line
400 if hasattr(method, "decorators") and method.decorators:
401 actual_start = method.decorators.lineno - 1
403 # Include comments above the method/decorators
404 comment_start = self._find_comments_above_function(lines, actual_start)
405 method_boundaries.append((method, comment_start))
407 # Second pass: create spans using the boundaries
408 for i, (method, comment_start) in enumerate(method_boundaries):
409 # Find the end line (start of next method or end of class)
410 if i + 1 < len(method_boundaries):
411 # End where the next method's comments start
412 end_line = method_boundaries[i + 1][1]
413 else:
414 # Last method in class, find end of class
415 end_line = (
416 class_node.end_lineno
417 if hasattr(class_node, "end_lineno")
418 else len(lines)
419 )
421 # Extract the text including comments
422 text = "".join(lines[comment_start:end_line])
424 spans.append(
425 FunctionSpan(
426 node=method,
427 start_line=comment_start,
428 end_line=end_line,
429 text=text,
430 name=method.name,
431 )
432 )
434 return spans
436 def _file_needs_sorting(self, content: str) -> bool:
437 """Check if a file needs function sorting.
439 :param content: File content as string
440 :type content: str
441 :returns: True if file needs sorting
442 :rtype: bool
443 """
444 try: # pylint: disable=too-many-nested-blocks
445 # Parse with astroid for consistency with the checker
446 module = astroid.parse(content)
448 # Check module-level functions
449 functions = utils.get_functions_from_node(module)
450 if functions:
451 sorted_result = utils.are_functions_sorted_with_exclusions(
452 functions, self.config.ignore_decorators
453 )
454 if not sorted_result:
455 return True
456 # Even if sorted, check if we need to add section headers
457 if self.config.add_section_headers:
458 function_spans = self._extract_function_spans(
459 functions, content.splitlines(), module
460 )
461 if self._has_mixed_visibility_functions(function_spans):
462 return True
464 # Check class methods
465 for node in module.body:
466 if isinstance(node, nodes.ClassDef):
467 methods = utils.get_methods_from_class(node)
468 if methods:
469 if not utils.are_methods_sorted_with_exclusions(
470 methods, self.config.ignore_decorators
471 ):
472 return True
473 # Even if sorted, check if we need to add section headers
474 if self.config.add_section_headers:
475 method_spans = self._extract_method_spans(
476 methods, content.splitlines(), node
477 )
478 if self._has_mixed_visibility_functions(method_spans):
479 return True
481 return False
483 except Exception: # pylint: disable=broad-exception-caught
484 return False
486 def _find_comments_above_function(
487 self, lines: List[str], function_start_line: int
488 ) -> int:
489 """Find comments that belong to a function and return the start line.
491 Scans backwards from the function definition to find associated comments.
492 Excludes section header comments that should remain at section boundaries.
494 :param lines: Source file lines
495 :type lines: List[str]
496 :param function_start_line: The line where the function starts (0-based)
497 :type function_start_line: int
498 :returns: The line number where comments start, or function_start_line
499 :rtype: int
500 """
501 comment_start_line = function_start_line
503 # Scan backwards from the function start to find comments
504 current_line = function_start_line - 1
505 found_function_comments = []
507 while current_line >= 0:
508 line = lines[current_line].strip()
510 # If we find a comment line, this could be part of the function's comments
511 if line.startswith("#"):
512 # Check if this is a section header comment
513 if self._is_section_header_comment(line):
514 # Section headers should not move with functions
515 # Stop including comments here
516 break
518 # This is a function-specific comment
519 found_function_comments.append(current_line)
520 comment_start_line = current_line
521 current_line -= 1
522 continue
524 # If we find an empty line, continue scanning (comments might be separated)
525 if line == "":
526 current_line -= 1
527 continue
529 # If we find any other content, stop scanning
530 break
532 return comment_start_line
534 def _find_existing_section_headers(self, lines: List[str]) -> Dict[str, int]:
535 """Find existing section headers in the source lines.
537 Returns a mapping of header types to their line numbers (0-based).
538 This helps avoid duplicating headers during automatic insertion.
540 :param lines: Source file lines
541 :type lines: List[str]
542 :returns: Dictionary mapping header type to line number
543 :rtype: dict[str, int]
544 """
545 headers = {}
547 for i, line in enumerate(lines):
548 stripped = line.strip()
549 if not stripped.startswith("#"):
550 continue
552 # Check if this matches any of our configured header patterns
553 lower_line = stripped.lower()
555 # Check for public function headers
556 if any(
557 keyword in lower_line
558 for keyword in ["public functions", "public function"]
559 ):
560 headers["public_functions"] = i
561 # Check for private function headers
562 elif any(
563 keyword in lower_line
564 for keyword in ["private functions", "private function"]
565 ):
566 headers["private_functions"] = i
567 # Check for public method headers
568 elif any(
569 keyword in lower_line for keyword in ["public methods", "public method"]
570 ):
571 headers["public_methods"] = i
572 # Check for private method headers
573 elif any(
574 keyword in lower_line
575 for keyword in ["private methods", "private method"]
576 ):
577 headers["private_methods"] = i
579 return headers
581 def _find_function_end(
582 self, lines: List[str], func: nodes.FunctionDef, module: nodes.Module
583 ) -> int:
584 """Find the actual end line of a function using AST-based boundary detection.
586 This method uses the module's AST to properly detect where module-level
587 constructs (assignments, if statements, other functions/classes) begin,
588 providing accurate boundaries without hardcoded pattern matching.
590 :param lines: Source file lines
591 :type lines: List[str]
592 :param func: Function node
593 :type func: nodes.FunctionDef
594 :param module: Module containing the function
595 :type module: nodes.Module
596 :returns: Line number where function ends (exclusive)
597 :rtype: int
598 """
599 func_end = func.end_lineno
601 # Find the next module-level construct after this function
602 next_construct_line = None
603 for node in module.body:
604 if node.lineno > func_end:
605 # This is the first construct after our function
606 next_construct_line = node.lineno
607 break
609 # If no module-level constructs follow, scan forward for trailing content
610 if next_construct_line is None:
611 # Look forward to include trailing comments/blank lines
612 i = func_end
613 while i < len(lines):
614 line = lines[i].strip()
615 # Include blank lines and comments that follow the function
616 if line == "" or line.startswith("#"):
617 i += 1
618 continue
619 # Stop at any non-empty, non-comment content
620 break # pragma: no cover
621 return int(i)
623 # We have a next construct - scan up to it, including preceding comments
624 i = func_end
625 while i < next_construct_line and i < len(lines):
626 line = lines[i].strip()
628 # Include blank lines and comments
629 if line == "" or line.startswith("#"):
630 i += 1
631 continue
633 # Stop if we hit content that belongs to the next construct
634 # (e.g., assignment statements, other code)
635 break
637 return int(i)
639 def _has_mixed_visibility_functions(self, spans: List[FunctionSpan]) -> bool:
640 """Check if spans contain both public and private functions.
642 Only add section headers when there are both public and private functions,
643 as per the requirement in issue #9.
645 :param spans: List of function spans to analyze
646 :type spans: List[FunctionSpan]
647 :returns: True if both public and private functions exist
648 :rtype: bool
649 """
650 has_public = False
651 has_private = False
653 for span in spans:
654 if utils.is_private_function(span.node):
655 has_private = True
656 else:
657 has_public = True
659 # Early exit if we've found both types
660 if has_public and has_private:
661 return True
663 return False
665 def _is_section_header_comment(self, comment_line: str) -> bool:
666 """Check if a comment line is a section header.
668 Section headers are comments that organize groups of functions/methods.
669 This method uses configurable patterns and automatically includes
670 the configured header texts from the AutoFixConfig.
672 :param comment_line: The comment line to check (already stripped)
673 :type comment_line: str
674 :returns: True if this is likely a section header comment
675 :rtype: bool
676 """
677 # Determine case sensitivity
678 comparison_comment = (
679 comment_line
680 if self.config.section_header_case_sensitive
681 else comment_line.lower()
682 )
684 # Build list of all patterns to check
685 patterns_to_check = []
687 # 1. Always include configured header texts (what we insert, we detect)
688 configured_headers = [
689 self.config.public_header,
690 self.config.private_header,
691 self.config.public_method_header,
692 self.config.private_method_header,
693 ]
695 # 1a. Add multi-category headers if enabled
696 if self.config.enable_multi_category_headers and self.config.category_config:
697 for category in self.config.category_config.categories:
698 if category.section_header:
699 configured_headers.append(category.section_header)
701 for header in configured_headers:
702 pattern = (
703 header if self.config.section_header_case_sensitive else header.lower()
704 )
705 patterns_to_check.append(pattern)
707 # 2. Add default fallback patterns for backward compatibility
708 default_keywords = [
709 "public functions",
710 "private functions",
711 "public methods",
712 "private methods",
713 "helper functions",
714 "utility functions",
715 "api functions",
716 "internal functions",
717 "exports",
718 "imports",
719 ]
721 default_organizational = [
722 "# functions",
723 "# methods",
724 "## functions",
725 "## methods",
726 "--- functions",
727 "--- methods",
728 "=== functions",
729 "=== methods",
730 ]
732 # Apply case sensitivity to defaults
733 if not self.config.section_header_case_sensitive:
734 default_keywords = [kw.lower() for kw in default_keywords]
735 default_organizational = [org.lower() for org in default_organizational]
737 patterns_to_check.extend(default_keywords)
738 patterns_to_check.extend(default_organizational)
740 # 3. Add user-configured additional patterns
741 if self.config.additional_section_patterns:
742 additional_patterns = self.config.additional_section_patterns[:]
743 if not self.config.section_header_case_sensitive:
744 additional_patterns = [
745 pattern.lower() for pattern in additional_patterns
746 ]
747 patterns_to_check.extend(additional_patterns)
749 # Check if the comment matches any pattern
750 for pattern in patterns_to_check:
751 if pattern in comparison_comment:
752 return True
754 return False
756 def _reconstruct_class_with_sorted_methods(
757 self,
758 content: str,
759 original_spans: List[FunctionSpan],
760 sorted_spans: List[FunctionSpan],
761 ) -> str:
762 """Reconstruct class content with sorted methods.
764 :param content: Original file content
765 :type content: str
766 :param original_spans: Original method spans in order of appearance
767 :type original_spans: List[FunctionSpan]
768 :param sorted_spans: Method spans in sorted order
769 :type sorted_spans: List[FunctionSpan]
770 :returns: Reconstructed content with sorted methods
771 :rtype: str
772 """
773 if not original_spans: # pragma: no cover
774 return content
776 # Find the region that contains all methods within the class
777 first_method_start = min(span.start_line for span in original_spans)
778 last_method_end = max(span.end_line for span in original_spans)
780 # Split content into lines for manipulation
781 content_lines = content.splitlines(keepends=True)
783 # Build new content
784 new_lines = []
786 # Add everything before the first method
787 new_lines.extend(content_lines[:first_method_start])
789 # Add sorted methods with optional section headers
790 method_lines = self._add_multi_category_section_headers_to_functions(
791 sorted_spans, is_methods=True
792 )
793 new_lines.extend(method_lines)
795 # Add everything after the last method
796 if last_method_end < len(content_lines):
797 new_lines.extend(content_lines[last_method_end:])
799 return "".join(new_lines)
801 def _reconstruct_content_with_sorted_functions(
802 self,
803 original_content: str,
804 original_spans: List[FunctionSpan],
805 sorted_spans: List[FunctionSpan],
806 ) -> str:
807 """Reconstruct file content with sorted functions.
809 Strategy:
810 1. Preserve everything before the first function (imports, module docstrings)
811 2. Replace the function block with sorted functions
812 3. Preserve everything after the last function
813 4. Add blank lines between functions if not already present
815 This approach ensures non-function content (imports, constants, etc.)
816 remains in its original position while only reordering functions.
818 :param original_content: Original file content
819 :type original_content: str
820 :param original_spans: Original function spans in order of appearance
821 :type original_spans: List[FunctionSpan]
822 :param sorted_spans: Function spans in sorted order
823 :type sorted_spans: List[FunctionSpan]
824 :returns: Reconstructed content with sorted functions
825 :rtype: str
826 """
827 if not original_spans: # pragma: no cover
828 return original_content
830 lines = original_content.splitlines(keepends=True)
832 # Find the region that contains all functions
833 first_func_start = min(span.start_line for span in original_spans)
834 last_func_end = max(span.end_line for span in original_spans)
836 # Build new content
837 new_lines = []
839 # Add everything before the first function
840 new_lines.extend(lines[:first_func_start])
842 # Add sorted functions with optional section headers
843 function_lines = self._add_multi_category_section_headers_to_functions(
844 sorted_spans, is_methods=False
845 )
846 new_lines.extend(function_lines)
848 # Add everything after the last function
849 if last_func_end < len(lines):
850 new_lines.extend(lines[last_func_end:])
852 return "".join(new_lines)
854 def _sort_class_methods(
855 self, content: str, module: nodes.Module, lines: List[str]
856 ) -> str:
857 """Sort methods within classes using multi-class safe processing.
859 CRITICAL FIX for GitHub issue #25: This method now processes all classes
860 in a single pass to prevent line number corruption when multiple classes
861 are present. The original implementation processed classes sequentially,
862 which caused subsequent classes to extract methods from wrong positions
863 after the content string was modified by earlier classes.
865 :param content: File content
866 :type content: str
867 :param module: Parsed module
868 :type module: nodes.Module
869 :param lines: Content split into lines
870 :type lines: List[str]
871 :returns: Content with sorted class methods
872 :rtype: str
873 """
874 # CRITICAL FIX: Extract ALL class information upfront before ANY
875 # modifications. This prevents line number corruption that causes
876 # class definitions to be lost
877 class_info = []
879 for node in module.body:
880 if isinstance(node, nodes.ClassDef):
881 methods = utils.get_methods_from_class(node)
882 if methods:
883 methods_already_sorted = utils.are_methods_sorted_with_exclusions(
884 methods, self.config.ignore_decorators
885 )
886 # Process class if methods need sorting or adding section headers
887 if not methods_already_sorted or self.config.add_section_headers:
888 # Extract method spans NOW while lines array is still accurate
889 method_spans = self._extract_method_spans(methods, lines, node)
890 sorted_spans = self._sort_function_spans(method_spans)
891 class_info.append((node, method_spans, sorted_spans))
893 if not class_info:
894 return content
896 # CRITICAL FIX: Process classes in REVERSE ORDER to preserve line numbers
897 # When we modify a class at the end of the file first, the line numbers
898 # for classes earlier in the file remain valid
899 for _, original_spans, sorted_spans in reversed(class_info):
900 # Reconstruct the class content with sorted methods
901 content = self._reconstruct_class_with_sorted_methods(
902 content, original_spans, sorted_spans
903 )
905 return content
907 def _sort_function_spans(self, spans: List[FunctionSpan]) -> List[FunctionSpan]:
908 """Sort function spans according to the plugin rules.
910 :param spans: List of function spans to sort
911 :type spans: List[FunctionSpan]
912 :returns: Sorted list of function spans
913 :rtype: List[FunctionSpan]
914 """
915 # Use multi-category sorting if enabled
916 if self.config.enable_multi_category_headers and self.config.category_config:
917 return self._sort_function_spans_by_categories(spans)
919 # Fall back to original binary public/private sorting
920 return self._sort_function_spans_binary(spans)
922 def _sort_function_spans_binary(
923 self, spans: List[FunctionSpan]
924 ) -> List[FunctionSpan]:
925 """Sort function spans using the original binary public/private system.
927 :param spans: List of function spans to sort
928 :type spans: List[FunctionSpan]
929 :returns: Sorted list of function spans
930 :rtype: List[FunctionSpan]
931 """
932 # Separate functions based on exclusions and visibility
933 excluded = []
934 sortable_public = []
935 sortable_private = []
937 for span in spans:
938 if utils.function_has_excluded_decorator(
939 span.node, self.config.ignore_decorators or []
940 ):
941 excluded.append(span)
942 elif utils.is_private_function(span.node):
943 sortable_private.append(span)
944 else:
945 sortable_public.append(span)
947 # Sort the sortable functions alphabetically
948 sortable_public.sort(key=lambda s: s.name)
949 sortable_private.sort(key=lambda s: s.name)
951 # Reconstruct the order: sortable public + sortable private + excluded
952 return sortable_public + sortable_private + excluded
954 def _sort_function_spans_by_categories(
955 self, spans: List[FunctionSpan]
956 ) -> List[FunctionSpan]:
957 """Sort function spans using the multi-category system.
959 :param spans: List of function spans to sort
960 :type spans: List[FunctionSpan]
961 :returns: Sorted list of function spans by categories
962 :rtype: List[FunctionSpan]
963 """
964 if not self.config.category_config:
965 return spans
967 # Separate excluded functions
968 excluded = []
969 sortable = []
971 for span in spans:
972 if utils.function_has_excluded_decorator(
973 span.node, self.config.ignore_decorators or []
974 ):
975 excluded.append(span)
976 else:
977 sortable.append(span)
979 # Group functions by category
980 categorized_functions: Dict[str, List[FunctionSpan]] = {}
982 for span in sortable:
983 category = categorize_method(span.node, self.config.category_config)
984 if category not in categorized_functions:
985 categorized_functions[category] = []
986 categorized_functions[category].append(span)
988 # Sort within each category if category_sorting is alphabetical
989 if self.config.category_config.category_sorting == "alphabetical":
990 for category_functions in categorized_functions.values():
991 category_functions.sort(key=lambda s: s.name)
993 # Reconstruct in category order
994 result = []
995 for category_def in self.config.category_config.categories:
996 if category_def.name in categorized_functions:
997 result.extend(categorized_functions[category_def.name])
999 # Add any excluded functions at the end
1000 result.extend(excluded)
1002 return result
1004 def _sort_functions_in_content(self, content: str) -> str:
1005 """Sort functions in file content and return new content.
1007 :param content: Original file content
1008 :type content: str
1009 :returns: Content with sorted functions
1010 :rtype: str
1011 """
1012 try:
1013 module = astroid.parse(content)
1014 lines = content.splitlines(keepends=True)
1016 # Process module-level functions
1017 content = self._sort_module_functions(content, module, lines)
1019 # Process class methods
1020 content = self._sort_class_methods(content, module, lines)
1022 return content
1024 except (
1025 Exception
1026 ) as e: # pragma: no cover # pylint: disable=broad-exception-caught
1027 print(f"Error sorting content: {e}")
1028 return content
1030 def _sort_module_functions(
1031 self, content: str, module: nodes.Module, lines: List[str]
1032 ) -> str:
1033 """Sort module-level functions.
1035 :param content: File content
1036 :type content: str
1037 :param module: Parsed module
1038 :type module: nodes.Module
1039 :param lines: Content split into lines
1040 :type lines: List[str]
1041 :returns: Content with sorted module functions
1042 :rtype: str
1043 """
1044 functions = utils.get_functions_from_node(module)
1045 if not functions: # pragma: no cover
1046 return content
1048 # Check if sorting is needed
1049 functions_already_sorted = utils.are_functions_sorted_with_exclusions(
1050 functions, self.config.ignore_decorators
1051 )
1053 # Even if functions are sorted, we might need to add section headers
1054 if functions_already_sorted and not self.config.add_section_headers:
1055 return content
1057 # Extract function spans
1058 function_spans = self._extract_function_spans(functions, lines, module)
1060 # Sort the functions
1061 sorted_spans = self._sort_function_spans(function_spans)
1063 # Reconstruct content
1064 return self._reconstruct_content_with_sorted_functions(
1065 content, function_spans, sorted_spans
1066 )
1068 def _validate_syntax_and_rollback(
1069 self, file_path: Path, original_content: str, new_content: str
1070 ) -> str:
1071 """Validate syntax after transformation, rollback if invalid.
1073 This is a critical safety measure to prevent the corruption described in
1074 GitHub issue #25. If auto-sort creates syntax errors, we automatically
1075 rollback to the original content to prevent data loss.
1077 :param file_path: Path to the file being processed (for error reporting)
1078 :type file_path: Path
1079 :param original_content: Original file content before transformation
1080 :type original_content: str
1081 :param new_content: New content after auto-sort transformation
1082 :type new_content: str
1083 :returns: Validated content (new_content if valid, original_content if invalid)
1084 :rtype: str
1085 """
1086 try:
1087 # Attempt to compile the new content to check for syntax errors
1088 compile(new_content, str(file_path), "exec")
1089 return new_content
1090 except SyntaxError as e:
1091 # Log the syntax error and rollback to prevent corruption
1092 print(f"WARNING: Auto-sort would create syntax error in {file_path}:")
1093 print(f" Error: {e}")
1094 if hasattr(e, "lineno") and e.lineno:
1095 if e.text:
1096 print(f" Line {e.lineno}: {e.text}")
1097 else:
1098 print(f" Line {e.lineno}") # pragma: no cover
1099 print(" Reverting to original content to prevent file corruption.")
1100 print(" This prevents the critical bug described in GitHub issue #25.")
1101 return original_content
1102 except Exception as e: # pragma: no cover
1103 # pylint: disable=broad-exception-caught
1104 # For any other compilation errors, be conservative and rollback
1105 print(f"WARNING: Could not validate syntax for {file_path}: {e}")
1106 print(" Reverting to original content as safety precaution.")
1107 return original_content
1110# Public functions
1113# Public API function for sorting a single file
1114def sort_python_file(file_path: Path, config: AutoFixConfig) -> bool: # pylint: disable=function-should-be-private
1115 """Sort functions in a Python file.
1117 :param file_path: Path to the Python file
1118 :type file_path: Path
1119 :param config: Auto-fix configuration
1120 :type config: AutoFixConfig
1121 :returns: True if file was modified
1122 :rtype: bool
1123 """
1124 return _sort_python_file(file_path, config)
1127def sort_python_files(file_paths: List[Path], config: AutoFixConfig) -> Tuple[int, int]:
1128 """Sort functions in multiple Python files.
1130 :param file_paths: List of Python file paths
1131 :type file_paths: List[Path]
1132 :param config: Auto-fix configuration
1133 :type config: AutoFixConfig
1134 :returns: Tuple of (files_processed, files_modified)
1135 :rtype: Tuple[int, int]
1136 """
1137 files_processed = 0
1138 files_modified = 0
1140 for file_path in file_paths:
1141 if file_path.suffix == ".py":
1142 files_processed += 1
1143 if _sort_python_file(file_path, config):
1144 files_modified += 1
1146 return files_processed, files_modified
1149# Private functions
1152def _sort_python_file(file_path: Path, config: AutoFixConfig) -> bool:
1153 """Sort functions in a Python file (private implementation).
1155 :param file_path: Path to the Python file
1156 :type file_path: Path
1157 :param config: Auto-fix configuration
1158 :type config: AutoFixConfig
1159 :returns: True if file was modified
1160 :rtype: bool
1161 """
1162 sorter = FunctionSorter(config)
1163 return sorter.sort_file(file_path)