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

87 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2025-08-12 16:06 +0200

1"""Safe test file modification and update operations for privacy fixing. 

2 

3This module provides functionality to safely update test files when functions 

4are privatized. It handles backing up files, making changes, validating syntax, 

5and rolling back if needed. 

6 

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

8""" 

9 

10import ast 

11import shutil 

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 FunctionTestReference 

17 

18 

19class TestFileUpdater: # pylint: disable=too-few-public-methods 

20 """Safe test file modification and update operations. 

21 

22 Handles updating test files with new function names while providing 

23 backup and rollback capabilities for safety. 

24 """ 

25 

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

27 """Initialize the test file updater. 

28 

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

30 """ 

31 self.backup = backup 

32 

33 # Public methods 

34 

35 def update_test_file( 

36 self, 

37 test_file: Path, 

38 old_name: str, 

39 new_name: str, 

40 test_references: List[FunctionTestReference], 

41 ) -> Dict[str, Any]: 

42 """Update a test file to use the new function name with backup and rollback. 

43 

44 This is the main entry point for safely updating test files. It creates 

45 a backup, applies updates, validates the result, and rolls back if needed. 

46 

47 :param test_file: Path to the test file to update 

48 :param old_name: Original function name 

49 :param new_name: New private function name (with underscore) 

50 :param test_references: List of test references to update 

51 :returns: Report of the update operation 

52 """ 

53 backup_file = None 

54 

55 try: 

56 # Create backup if backup is enabled 

57 if self.backup: 

58 backup_file = Path(f"{test_file}.bak") 

59 shutil.copy2(test_file, backup_file) 

60 

61 # Track changes made 

62 import_changes = False 

63 mock_changes = False 

64 

65 # Apply import statement updates 

66 if any(ref.context == "import" for ref in test_references): 

67 import_changes = self._update_import_statements( 

68 test_file, old_name, new_name, test_references 

69 ) 

70 

71 # Apply mock pattern updates 

72 if any(ref.context == "mock_patch" for ref in test_references): 

73 mock_changes = self._update_mock_patterns( 

74 test_file, old_name, new_name, test_references 

75 ) 

76 

77 # Validate the updated file syntax 

78 if import_changes or mock_changes: 

79 try: 

80 with open(test_file, "r", encoding="utf-8") as f: 

81 content = f.read() 

82 ast.parse(content) # This will raise SyntaxError if invalid 

83 

84 return { 

85 "success": True, 

86 "file": str(test_file), 

87 "backup": str(backup_file) if backup_file else None, 

88 "import_changes": import_changes, 

89 "mock_changes": mock_changes, 

90 "total_references": len(test_references), 

91 } 

92 

93 except SyntaxError: 

94 # Syntax validation failed - rollback 

95 if backup_file and backup_file.exists(): 

96 shutil.copy2(backup_file, test_file) 

97 backup_file.unlink() # Remove the backup since we used it 

98 

99 return { 

100 "success": False, 

101 "error": "Syntax validation failed - changes rolled back", 

102 "file": str(test_file), 

103 } 

104 else: 

105 # No changes needed 

106 if backup_file and backup_file.exists(): 

107 backup_file.unlink() # Remove unnecessary backup 

108 

109 return { 

110 "success": True, 

111 "file": str(test_file), 

112 "import_changes": False, 

113 "mock_changes": False, 

114 "total_references": len(test_references), 

115 } 

116 

117 except Exception as e: # pylint: disable=broad-exception-caught 

118 # General error - rollback if possible 

119 if backup_file and backup_file.exists(): 

120 try: 

121 shutil.copy2(backup_file, test_file) 

122 backup_file.unlink() 

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

124 pass # Best effort rollback 

125 

126 return { 

127 "success": False, 

128 "error": f"Update failed: {str(e)}", 

129 "file": str(test_file), 

130 } 

131 

132 # Private methods 

133 

134 def _update_import_statements( # pylint: disable=too-many-nested-blocks 

135 self, 

136 test_file: Path, 

137 old_name: str, 

138 new_name: str, 

139 test_references: List[FunctionTestReference], 

140 ) -> bool: 

141 """Update import statements in a test file to use the new function name. 

142 

143 This method handles AST-based modifications of import statements to replace 

144 old function names with new private function names. 

145 

146 :param test_file: Path to the test file to update 

147 :param old_name: Original function name 

148 :param new_name: New private function name (with underscore) 

149 :param test_references: List of test references to update 

150 :returns: True if file was successfully updated 

151 """ 

152 try: 

153 # Read the current file content 

154 with open(test_file, "r", encoding="utf-8") as f: 

155 content = f.read() 

156 

157 # Track if any changes were made 

158 changes_made = False 

159 lines = content.split("\n") 

160 

161 # Process each import-related test reference 

162 for ref in test_references: 

163 if ref.context == "import": 

164 # For multi-line imports, the reference line might not be the 

165 # ImportFrom node line. So we need to check if the specific 

166 # line contains the function name 

167 line_idx = ref.line - 1 # Convert to 0-based index 

168 if line_idx < len(lines): 

169 old_line = lines[line_idx] 

170 # Check if this line contains the function name to replace 

171 if old_name in old_line: 

172 # Replace the function name in various patterns 

173 new_line = ( 

174 old_line.replace(f" {old_name}", f" {new_name}") 

175 .replace(f" {old_name},", f" {new_name},") 

176 .replace(f"({old_name}", f"({new_name}") 

177 .replace(f"({old_name},", f"({new_name},") 

178 .replace(f" {old_name},", f" {new_name},") 

179 .replace(f" {old_name}", f" {new_name}") 

180 ) 

181 

182 if new_line != old_line: 

183 lines[line_idx] = new_line 

184 changes_made = True 

185 

186 # Write the updated content back to the file if changes were made 

187 if changes_made: 

188 updated_content = "\n".join(lines) 

189 with open(test_file, "w", encoding="utf-8") as f: 

190 f.write(updated_content) 

191 

192 return changes_made 

193 

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

195 # If file operations fail, return False 

196 return False 

197 

198 def _update_mock_patterns( 

199 self, 

200 test_file: Path, 

201 old_name: str, 

202 new_name: str, 

203 test_references: List[FunctionTestReference], 

204 ) -> bool: 

205 """Update mock patch patterns in a test file to use the new function name. 

206 

207 This method handles string-based modifications of mock patches to replace 

208 old function names with new private function names. 

209 

210 :param test_file: Path to the test file to update 

211 :param old_name: Original function name 

212 :param new_name: New private function name (with underscore) 

213 :param test_references: List of test references to update 

214 :returns: True if file was successfully updated 

215 """ 

216 try: 

217 # Read the current file content 

218 with open(test_file, "r", encoding="utf-8") as f: 

219 content = f.read() 

220 

221 # Track if any changes were made 

222 changes_made = False 

223 lines = content.split("\n") 

224 

225 # Process each mock-related test reference 

226 for ref in test_references: 

227 if ref.context == "mock_patch": 

228 # Update the specific line containing the mock patch 

229 line_idx = ref.line - 1 # Convert to 0-based index 

230 if line_idx < len(lines): 

231 old_line = lines[line_idx] 

232 # Replace the function name in the mock patch string 

233 # Handle both single and double quotes 

234 new_line = old_line.replace( 

235 f".{old_name}'", f".{new_name}'" 

236 ).replace(f'.{old_name}"', f'.{new_name}"') 

237 

238 if new_line != old_line: 

239 lines[line_idx] = new_line 

240 changes_made = True 

241 

242 # Write the updated content back to the file if changes were made 

243 if changes_made: 

244 updated_content = "\n".join(lines) 

245 with open(test_file, "w", encoding="utf-8") as f: 

246 f.write(updated_content) 

247 

248 return changes_made 

249 

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

251 # If file operations fail, return False 

252 return False