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

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

2

3 This module provides functionality for safe file operations including backup

4 creation, content modification, syntax validation, and rollback mechanisms.

5 Used by the privacy fixing system to ensure safe updates.

6

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

8"""

9

10 import ast

11 import re

12 from pathlib import Path

13 from typing import Any, Dict, List

14

15# Import types that will be referenced

16 from pylint_sort_functions.privacy_types import RenameCandidate

17

18

19 class 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