Coverage for /Users/OORDCOR/Documents/code/bump-my-version/bumpversion/files.py: 86%

137 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2024-02-24 07:45 -0600

1"""Methods for changing files.""" 

2 

3import os.path 

4import re 

5from copy import deepcopy 

6from difflib import context_diff 

7from pathlib import Path 

8from typing import Dict, List, MutableMapping, Optional 

9 

10from bumpversion.config.models import FileChange 

11from bumpversion.exceptions import VersionNotFoundError 

12from bumpversion.ui import get_indented_logger 

13from bumpversion.utils import get_nested_value, set_nested_value 

14from bumpversion.version_part import VersionConfig 

15from bumpversion.versioning.models import Version, VersionComponentSpec 

16 

17logger = get_indented_logger(__name__) 

18 

19 

20def contains_pattern(search: re.Pattern, contents: str) -> bool: 

21 """Does the search pattern match any part of the contents?""" 

22 if not search or not contents: 22 ↛ 23line 22 didn't jump to line 23, because the condition on line 22 was never true

23 return False 

24 

25 for m in re.finditer(search, contents): 

26 line_no = contents.count("\n", 0, m.start(0)) + 1 

27 logger.info( 

28 "Found '%s' at line %s: %s", 

29 search.pattern, 

30 line_no, 

31 m.string[m.start() : m.end(0)], 

32 ) 

33 return True 

34 return False 

35 

36 

37def log_changes(file_path: str, file_content_before: str, file_content_after: str, dry_run: bool = False) -> None: 

38 """ 

39 Log the changes that would be made to the file. 

40 

41 Args: 

42 file_path: The path to the file 

43 file_content_before: The file contents before the change 

44 file_content_after: The file contents after the change 

45 dry_run: True if this is a report-only job 

46 """ 

47 if file_content_before != file_content_after: 

48 logger.info("%s file %s:", "Would change" if dry_run else "Changing", file_path) 

49 logger.indent() 

50 indent_str = logger.indent_str 

51 

52 logger.info( 

53 f"\n{indent_str}".join( 

54 list( 

55 context_diff( 

56 file_content_before.splitlines(), 

57 file_content_after.splitlines(), 

58 fromfile=f"before {file_path}", 

59 tofile=f"after {file_path}", 

60 lineterm="", 

61 ) 

62 ) 

63 ), 

64 ) 

65 logger.dedent() 

66 else: 

67 logger.info("%s file %s", "Would not change" if dry_run else "Not changing", file_path) 

68 

69 

70class ConfiguredFile: 

71 """A file to modify in a configured way.""" 

72 

73 def __init__( 

74 self, 

75 file_change: FileChange, 

76 version_config: VersionConfig, 

77 search: Optional[str] = None, 

78 replace: Optional[str] = None, 

79 ) -> None: 

80 replacements = [replace, file_change.replace, version_config.replace] 

81 replacement = next((r for r in replacements if r is not None), "") 81 ↛ exitline 81 didn't finish the generator expression on line 81

82 self.file_change = FileChange( 

83 parse=file_change.parse or version_config.parse_regex.pattern, 

84 serialize=file_change.serialize or version_config.serialize_formats, 

85 search=search or file_change.search or version_config.search, 

86 replace=replacement, 

87 regex=file_change.regex or False, 

88 ignore_missing_version=file_change.ignore_missing_version or False, 

89 ignore_missing_file=file_change.ignore_missing_file or False, 

90 filename=file_change.filename, 

91 glob=file_change.glob, 

92 key_path=file_change.key_path, 

93 ) 

94 self.version_config = VersionConfig( 

95 self.file_change.parse, 

96 self.file_change.serialize, 

97 self.file_change.search, 

98 self.file_change.replace, 

99 version_config.part_configs, 

100 ) 

101 self._newlines: Optional[str] = None 

102 

103 def get_file_contents(self) -> str: 

104 """ 

105 Return the contents of the file. 

106 

107 Raises: 

108 FileNotFoundError: if the file doesn't exist 

109 

110 Returns: 

111 The contents of the file 

112 """ 

113 if not os.path.exists(self.file_change.filename): 113 ↛ 114line 113 didn't jump to line 114, because the condition on line 113 was never true

114 raise FileNotFoundError(f"File not found: '{self.file_change.filename}'") # pragma: no-coverage 

115 

116 with open(self.file_change.filename, "rt", encoding="utf-8") as f: 

117 contents = f.read() 

118 self._newlines = f.newlines[0] if isinstance(f.newlines, tuple) else f.newlines 

119 return contents 

120 

121 def write_file_contents(self, contents: str) -> None: 

122 """Write the contents of the file.""" 

123 if self._newlines is None: 

124 _ = self.get_file_contents() 

125 

126 with open(self.file_change.filename, "wt", encoding="utf-8", newline=self._newlines) as f: 

127 f.write(contents) 

128 

129 def _contains_change_pattern( 

130 self, search_expression: re.Pattern, raw_search_expression: str, version: Version, context: MutableMapping 

131 ) -> bool: 

132 """ 

133 Does the file contain the change pattern? 

134 

135 Args: 

136 search_expression: The compiled search expression 

137 raw_search_expression: The raw search expression 

138 version: The version to check, in case it's not the same as the original 

139 context: The context to use 

140 

141 Raises: 

142 VersionNotFoundError: if the version number isn't present in this file. 

143 

144 Returns: 

145 True if the version number is in fact present. 

146 """ 

147 file_contents = self.get_file_contents() 

148 if contains_pattern(search_expression, file_contents): 

149 return True 

150 

151 # The `search` pattern did not match, but the original supplied 

152 # version number (representing the same version part values) might 

153 # match instead. This is probably the case if environment variables are used. 

154 

155 # check whether `search` isn't customized 

156 search_pattern_is_default = self.file_change.search == self.version_config.search 

157 

158 if search_pattern_is_default and contains_pattern(re.compile(re.escape(version.original)), file_contents): 

159 # The original version is present, and we're not looking for something 

160 # more specific -> this is accepted as a match 

161 return True 

162 

163 # version not found 

164 if self.file_change.ignore_missing_version: 

165 return False 

166 raise VersionNotFoundError(f"Did not find '{raw_search_expression}' in file: '{self.file_change.filename}'") 

167 

168 def make_file_change( 

169 self, current_version: Version, new_version: Version, context: MutableMapping, dry_run: bool = False 

170 ) -> None: 

171 """Make the change to the file.""" 

172 logger.info( 

173 "\n%sFile %s: replace `%s` with `%s`", 

174 logger.indent_str, 

175 self.file_change.filename, 

176 self.file_change.search, 

177 self.file_change.replace, 

178 ) 

179 logger.indent() 

180 if not os.path.exists(self.file_change.filename): 180 ↛ 181line 180 didn't jump to line 181, because the condition on line 180 was never true

181 if self.file_change.ignore_missing_file: 

182 logger.info("File not found, but ignoring") 

183 logger.dedent() 

184 return 

185 raise FileNotFoundError(f"File not found: '{self.file_change.filename}'") # pragma: no-coverage 

186 logger.debug("Serializing the current version") 

187 logger.indent() 

188 context["current_version"] = self.version_config.serialize(current_version, context) 

189 logger.dedent() 

190 if new_version: 190 ↛ 196line 190 didn't jump to line 196, because the condition on line 190 was never false

191 logger.debug("Serializing the new version") 

192 logger.indent() 

193 context["new_version"] = self.version_config.serialize(new_version, context) 

194 logger.dedent() 

195 else: 

196 logger.debug("No new version, using current version as new version") 

197 context["new_version"] = context["current_version"] 

198 

199 search_for, raw_search_pattern = self.file_change.get_search_pattern(context) 

200 replace_with = self.version_config.replace.format(**context) 

201 

202 if not self._contains_change_pattern(search_for, raw_search_pattern, current_version, context): 

203 return 

204 

205 file_content_before = self.get_file_contents() 

206 

207 file_content_after = search_for.sub(replace_with, file_content_before) 

208 

209 if file_content_before == file_content_after and current_version.original: 

210 og_context = deepcopy(context) 

211 og_context["current_version"] = current_version.original 

212 search_for_og, og_raw_search_pattern = self.file_change.get_search_pattern(og_context) 

213 file_content_after = search_for_og.sub(replace_with, file_content_before) 

214 

215 log_changes(self.file_change.filename, file_content_before, file_content_after, dry_run) 

216 logger.dedent() 

217 if not dry_run: # pragma: no-coverage 

218 self.write_file_contents(file_content_after) 

219 

220 def __str__(self) -> str: # pragma: no-coverage 

221 return self.file_change.filename 

222 

223 def __repr__(self) -> str: # pragma: no-coverage 

224 return f"<bumpversion.ConfiguredFile:{self.file_change.filename}>" 

225 

226 

227def resolve_file_config( 

228 files: List[FileChange], version_config: VersionConfig, search: Optional[str] = None, replace: Optional[str] = None 

229) -> List[ConfiguredFile]: 

230 """ 

231 Resolve the files, searching and replacing values according to the FileConfig. 

232 

233 Args: 

234 files: A list of file configurations 

235 version_config: How the version should be changed 

236 search: The search pattern to use instead of any configured search pattern 

237 replace: The replace pattern to use instead of any configured replace pattern 

238 

239 Returns: 

240 A list of ConfiguredFiles 

241 """ 

242 return [ConfiguredFile(file_cfg, version_config, search, replace) for file_cfg in files] 

243 

244 

245def modify_files( 

246 files: List[ConfiguredFile], 

247 current_version: Version, 

248 new_version: Version, 

249 context: MutableMapping, 

250 dry_run: bool = False, 

251) -> None: 

252 """ 

253 Modify the files, searching and replacing values according to the FileConfig. 

254 

255 Args: 

256 files: The list of configured files 

257 current_version: The current version 

258 new_version: The next version 

259 context: The context used for rendering the version 

260 dry_run: True if this should be a report-only job 

261 """ 

262 # _check_files_contain_version(files, current_version, context) 

263 for f in files: 

264 f.make_file_change(current_version, new_version, context, dry_run) 

265 

266 

267class FileUpdater: 

268 """A class to handle updating files.""" 

269 

270 def __init__( 

271 self, 

272 file_change: FileChange, 

273 version_config: VersionConfig, 

274 search: Optional[str] = None, 

275 replace: Optional[str] = None, 

276 ) -> None: 

277 self.file_change = FileChange( 

278 parse=file_change.parse or version_config.parse_regex.pattern, 

279 serialize=file_change.serialize or version_config.serialize_formats, 

280 search=search or file_change.search or version_config.search, 

281 replace=replace or file_change.replace or version_config.replace, 

282 regex=file_change.regex or False, 

283 ignore_missing_file=file_change.ignore_missing_file or False, 

284 ignore_missing_version=file_change.ignore_missing_version or False, 

285 filename=file_change.filename, 

286 glob=file_change.glob, 

287 key_path=file_change.key_path, 

288 ) 

289 self.version_config = VersionConfig( 

290 self.file_change.parse, 

291 self.file_change.serialize, 

292 self.file_change.search, 

293 self.file_change.replace, 

294 version_config.part_configs, 

295 ) 

296 self._newlines: Optional[str] = None 

297 

298 def update_file( 

299 self, current_version: Version, new_version: Version, context: MutableMapping, dry_run: bool = False 

300 ) -> None: 

301 """Update the files.""" 

302 # TODO: Implement this 

303 pass 

304 

305 

306class DataFileUpdater: 

307 """A class to handle updating files.""" 

308 

309 def __init__( 

310 self, 

311 file_change: FileChange, 

312 version_part_configs: Dict[str, VersionComponentSpec], 

313 ) -> None: 

314 self.file_change = file_change 

315 self.version_config = VersionConfig( 

316 self.file_change.parse, 

317 self.file_change.serialize, 

318 self.file_change.search, 

319 self.file_change.replace, 

320 version_part_configs, 

321 ) 

322 self.path = Path(self.file_change.filename) 

323 self._newlines: Optional[str] = None 

324 

325 def update_file( 

326 self, current_version: Version, new_version: Version, context: MutableMapping, dry_run: bool = False 

327 ) -> None: 

328 """Update the files.""" 

329 new_context = deepcopy(context) 

330 new_context["current_version"] = self.version_config.serialize(current_version, context) 

331 new_context["new_version"] = self.version_config.serialize(new_version, context) 

332 search_for, raw_search_pattern = self.file_change.get_search_pattern(new_context) 

333 replace_with = self.file_change.replace.format(**new_context) 

334 if self.path.suffix == ".toml": 

335 self._update_toml_file(search_for, raw_search_pattern, replace_with, dry_run) 

336 

337 def _update_toml_file( 

338 self, search_for: re.Pattern, raw_search_pattern: str, replace_with: str, dry_run: bool = False 

339 ) -> None: 

340 """Update a TOML file.""" 

341 import tomlkit 

342 

343 toml_data = tomlkit.parse(self.path.read_text(encoding="utf-8")) 

344 value_before = get_nested_value(toml_data, self.file_change.key_path) 

345 

346 if value_before is None: 346 ↛ 347line 346 didn't jump to line 347, because the condition on line 346 was never true

347 raise KeyError(f"Key path '{self.file_change.key_path}' does not exist in {self.path}") 

348 elif not contains_pattern(search_for, value_before) and not self.file_change.ignore_missing_version: 348 ↛ 349line 348 didn't jump to line 349, because the condition on line 348 was never true

349 raise ValueError( 

350 f"Key '{self.file_change.key_path}' in {self.path} does not contain the correct contents: " 

351 f"{raw_search_pattern}" 

352 ) 

353 

354 new_value = search_for.sub(replace_with, value_before) 

355 log_changes(f"{self.path}:{self.file_change.key_path}", value_before, new_value, dry_run) 

356 

357 if dry_run: 

358 return 

359 

360 set_nested_value(toml_data, new_value, self.file_change.key_path) 

361 

362 self.path.write_text(tomlkit.dumps(toml_data))