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

1"""Safe file operations with backup and rollback capabilities for privacy fixing. 

2 

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. 

6 

7Part of the refactoring described in GitHub Issue #32. 

8""" 

9 

10import ast 

11import re 

12from pathlib import Path 

13from typing import Any, Dict, List 

14 

15# Import types that will be referenced 

16from pylint_sort_functions.privacy_types import RenameCandidate 

17 

18 

19class FileOperations: 

20 """Safe file operations with backup and rollback capabilities. 

21 

22 Handles file backup, content modification, validation, and recovery 

23 operations for safe privacy fixing transformations. 

24 """ 

25 

26 def __init__(self, backup: bool = True): 

27 """Initialize the file operations handler. 

28 

29 :param backup: If True, create .bak files before modifying originals 

30 """ 

31 self.backup = backup 

32 

33 # Public methods 

34 

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. 

39 

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 } 

53 

54 try: 

55 # Read the original file content 

56 original_content = self.read_file(file_path) 

57 

58 # Create backup if requested 

59 backup_path = None 

60 if self.backup: 

61 backup_path = self.create_backup(file_path) 

62 

63 # Apply renames to the content 

64 modified_content = self._apply_renames_to_content( 

65 original_content, candidates 

66 ) 

67 

68 # Write the modified content back to the file 

69 if modified_content != original_content: 

70 self.write_file(file_path, modified_content) 

71 

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 ) 

83 

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 } 

90 

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 } 

97 

98 def cleanup_backup(self, backup_path: Path) -> None: 

99 """Remove a backup file if it exists. 

100 

101 :param backup_path: Path to the backup file to remove 

102 """ 

103 if backup_path.exists(): 

104 backup_path.unlink() 

105 

106 def create_backup(self, file_path: Path) -> Path: 

107 """Create a backup of a file before modification. 

108 

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 

116 

117 def read_file(self, file_path: Path) -> str: 

118 """Read content from a file with UTF-8 encoding. 

119 

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

125 

126 def restore_from_backup(self, file_path: Path, backup_path: Path) -> None: 

127 """Restore a file from its backup. 

128 

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) 

135 

136 def validate_syntax(self, file_path: Path) -> bool: 

137 """Validate that a Python file has correct syntax. 

138 

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 

148 

149 # Private methods 

150 

151 def write_file(self, file_path: Path, content: str) -> None: 

152 """Write content to a file with UTF-8 encoding. 

153 

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) 

159 

160 def _apply_renames_to_content( 

161 self, content: str, candidates: List[RenameCandidate] 

162 ) -> str: 

163 """Apply function name renames to file content. 

164 

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 

169 

170 :param content: Original file content 

171 :param candidates: List of rename candidates 

172 :returns: Modified file content 

173 """ 

174 modified_content = content 

175 

176 # Only process safe candidates 

177 safe_candidates = [c for c in candidates if c.is_safe] 

178 

179 for candidate in safe_candidates: 

180 old_name = candidate.old_name 

181 new_name = candidate.new_name 

182 

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" 

190 

191 modified_content = re.sub(pattern, new_name, modified_content) 

192 

193 return modified_content