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

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

2

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

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

5 and rolling back if needed.

6

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

8"""

9

10 import ast

11 import shutil

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 FunctionTestReference

17

18

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