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
« 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.
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.
7Part of the refactoring described in GitHub Issue #32.
8"""
10import ast
11import shutil
12from pathlib import Path
13from typing import Any, Dict, List
15# Import types that will be referenced
16from pylint_sort_functions.privacy_types import FunctionTestReference
19class TestFileUpdater: # pylint: disable=too-few-public-methods
20 """Safe test file modification and update operations.
22 Handles updating test files with new function names while providing
23 backup and rollback capabilities for safety.
24 """
26 def __init__(self, backup: bool = True):
27 """Initialize the test file updater.
29 :param backup: If True, create .bak files before modifying originals
30 """
31 self.backup = backup
33 # Public methods
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.
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.
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
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)
61 # Track changes made
62 import_changes = False
63 mock_changes = False
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 )
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 )
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
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 }
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
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
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 }
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
126 return {
127 "success": False,
128 "error": f"Update failed: {str(e)}",
129 "file": str(test_file),
130 }
132 # Private methods
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.
143 This method handles AST-based modifications of import statements to replace
144 old function names with new private function names.
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()
157 # Track if any changes were made
158 changes_made = False
159 lines = content.split("\n")
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 )
182 if new_line != old_line:
183 lines[line_idx] = new_line
184 changes_made = True
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)
192 return changes_made
194 except Exception: # pylint: disable=broad-exception-caught
195 # If file operations fail, return False
196 return False
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.
207 This method handles string-based modifications of mock patches to replace
208 old function names with new private function names.
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()
221 # Track if any changes were made
222 changes_made = False
223 lines = content.split("\n")
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}"')
238 if new_line != old_line:
239 lines[line_idx] = new_line
240 changes_made = True
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)
248 return changes_made
250 except Exception: # pylint: disable=broad-exception-caught
251 # If file operations fail, return False
252 return False