Coverage for src/pylint_sort_functions/file_operations.py: 100%
56 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"""Safe file operations with backup and rollback capabilities for privacy fixing.
3This module provides functionality for safe file operations including backup
4creation, content modification, syntax validation, and rollback mechanisms.
5Used by the privacy fixing system to ensure safe updates.
7Part of the refactoring described in GitHub Issue #32.
8"""
10import ast
11import re
12from pathlib import Path
13from typing import Any, Dict, List
15# Import types that will be referenced
16from pylint_sort_functions.privacy_types import RenameCandidate
19class FileOperations:
20 """Safe file operations with backup and rollback capabilities.
22 Handles file backup, content modification, validation, and recovery
23 operations for safe privacy fixing transformations.
24 """
26 def __init__(self, backup: bool = True):
27 """Initialize the file operations handler.
29 :param backup: If True, create .bak files before modifying originals
30 """
31 self.backup = backup
33 # Public methods
35 def apply_renames_to_file(
36 self, file_path: Path, candidates: List[RenameCandidate], dry_run: bool = False
37 ) -> Dict[str, Any]:
38 """Apply renames to a specific file with backup and validation.
40 :param file_path: Path to the file to modify
41 :param candidates: List of rename candidates for this file
42 :param dry_run: If True, only report what would be changed without applying
43 :returns: Report of changes made to this file
44 """
45 if dry_run:
46 # In dry-run mode, just report what would be changed
47 return {
48 "renamed": len([c for c in candidates if c.is_safe]),
49 "skipped": len([c for c in candidates if not c.is_safe]),
50 "errors": [],
51 "dry_run": True,
52 }
54 try:
55 # Read the original file content
56 original_content = self.read_file(file_path)
58 # Create backup if requested
59 backup_path = None
60 if self.backup:
61 backup_path = self.create_backup(file_path)
63 # Apply renames to the content
64 modified_content = self._apply_renames_to_content(
65 original_content, candidates
66 )
68 # Write the modified content back to the file
69 if modified_content != original_content:
70 self.write_file(file_path, modified_content)
72 # Validate syntax after modification
73 modified_syntax_valid = (
74 modified_content == original_content or self.validate_syntax(file_path)
75 )
76 if not modified_syntax_valid:
77 # Restore from backup if validation fails
78 if backup_path: # pragma: no cover
79 self.restore_from_backup(file_path, backup_path) # pragma: no cover
80 raise SyntaxError( # pragma: no cover
81 "Modified file has invalid syntax"
82 )
84 return {
85 "renamed": len([c for c in candidates if c.is_safe]),
86 "skipped": len([c for c in candidates if not c.is_safe]),
87 "errors": [],
88 "backup": str(backup_path) if backup_path else None,
89 }
91 except Exception as e: # pylint: disable=broad-exception-caught
92 return {
93 "renamed": 0,
94 "skipped": len(candidates),
95 "errors": [f"Failed to process {file_path}: {str(e)}"],
96 }
98 def cleanup_backup(self, backup_path: Path) -> None:
99 """Remove a backup file if it exists.
101 :param backup_path: Path to the backup file to remove
102 """
103 if backup_path.exists():
104 backup_path.unlink()
106 def create_backup(self, file_path: Path) -> Path:
107 """Create a backup of a file before modification.
109 :param file_path: Path to the file to back up
110 :returns: Path to the backup file
111 """
112 backup_path = file_path.with_suffix(file_path.suffix + ".bak")
113 content = self.read_file(file_path)
114 self.write_file(backup_path, content)
115 return backup_path
117 def read_file(self, file_path: Path) -> str:
118 """Read content from a file with UTF-8 encoding.
120 :param file_path: Path to the file to read
121 :returns: File content as string
122 """
123 with open(file_path, "r", encoding="utf-8") as f:
124 return f.read()
126 def restore_from_backup(self, file_path: Path, backup_path: Path) -> None:
127 """Restore a file from its backup.
129 :param file_path: Path to the file to restore
130 :param backup_path: Path to the backup file
131 """
132 if backup_path.exists():
133 content = self.read_file(backup_path)
134 self.write_file(file_path, content)
136 def validate_syntax(self, file_path: Path) -> bool:
137 """Validate that a Python file has correct syntax.
139 :param file_path: Path to the file to validate
140 :returns: True if syntax is valid, False otherwise
141 """
142 try:
143 content = self.read_file(file_path)
144 ast.parse(content)
145 return True
146 except (SyntaxError, UnicodeDecodeError): # pragma: no cover
147 return False # pragma: no cover
149 # Private methods
151 def write_file(self, file_path: Path, content: str) -> None:
152 """Write content to a file with UTF-8 encoding.
154 :param file_path: Path to the file to write
155 :param content: Content to write
156 """
157 with open(file_path, "w", encoding="utf-8") as f:
158 f.write(content)
160 def _apply_renames_to_content(
161 self, content: str, candidates: List[RenameCandidate]
162 ) -> str:
163 """Apply function name renames to file content.
165 This uses a conservative string replacement approach that:
166 1. Only processes safe candidates
167 2. Uses word boundaries to avoid partial matches
168 3. Preserves original formatting and structure
170 :param content: Original file content
171 :param candidates: List of rename candidates
172 :returns: Modified file content
173 """
174 modified_content = content
176 # Only process safe candidates
177 safe_candidates = [c for c in candidates if c.is_safe]
179 for candidate in safe_candidates:
180 old_name = candidate.old_name
181 new_name = candidate.new_name
183 # Use word boundaries to ensure we only match complete function names
184 # This pattern matches:
185 # - Function definitions: def old_name(
186 # - Function calls: old_name(
187 # - Assignments: var = old_name
188 # - Decorators: @old_name
189 pattern = rf"\b{re.escape(old_name)}\b"
191 modified_content = re.sub(pattern, new_name, modified_content)
193 return modified_content