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

138 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-06-12 09:28 -0500

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 include_bumps=file_change.include_bumps, 

94 exclude_bumps=file_change.exclude_bumps, 

95 ) 

96 self.version_config = VersionConfig( 

97 self.file_change.parse, 

98 self.file_change.serialize, 

99 self.file_change.search, 

100 self.file_change.replace, 

101 version_config.part_configs, 

102 ) 

103 self._newlines: Optional[str] = None 

104 

105 def get_file_contents(self) -> str: 

106 """ 

107 Return the contents of the file. 

108 

109 Raises: 

110 FileNotFoundError: if the file doesn't exist 

111 

112 Returns: 

113 The contents of the file 

114 """ 

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

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

117 

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

119 contents = f.read() 

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

121 return contents 

122 

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

124 """Write the contents of the file.""" 

125 if self._newlines is None: 125 ↛ 126line 125 didn't jump to line 126, because the condition on line 125 was never true

126 _ = self.get_file_contents() 

127 

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

129 f.write(contents) 

130 

131 def _contains_change_pattern( 

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

133 ) -> bool: 

134 """ 

135 Does the file contain the change pattern? 

136 

137 Args: 

138 search_expression: The compiled search expression 

139 raw_search_expression: The raw search expression 

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

141 context: The context to use 

142 

143 Raises: 

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

145 

146 Returns: 

147 True if the version number is in fact present. 

148 """ 

149 file_contents = self.get_file_contents() 

150 if contains_pattern(search_expression, file_contents): 150 ↛ 158line 150 didn't jump to line 158, because the condition on line 150 was never false

151 return True 

152 

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

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

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

156 

157 # check whether `search` isn't customized 

158 search_pattern_is_default = self.file_change.search == self.version_config.search 

159 

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

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

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

163 return True 

164 

165 # version not found 

166 if self.file_change.ignore_missing_version: 

167 return False 

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

169 

170 def make_file_change( 

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

172 ) -> None: 

173 """Make the change to the file.""" 

174 logger.info( 

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

176 logger.indent_str, 

177 self.file_change.filename, 

178 self.file_change.search, 

179 self.file_change.replace, 

180 ) 

181 logger.indent() 

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

183 if self.file_change.ignore_missing_file: 

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

185 logger.dedent() 

186 return 

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

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

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

190 context["new_version"] = self._get_serialized_version("new_version", new_version, context) 

191 else: 

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

193 context["new_version"] = context["current_version"] 

194 

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

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

197 

198 if not self._contains_change_pattern(search_for, raw_search_pattern, current_version, context): 198 ↛ 199line 198 didn't jump to line 199, because the condition on line 198 was never true

199 logger.dedent() 

200 return 

201 

202 file_content_before = self.get_file_contents() 

203 

204 file_content_after = search_for.sub(replace_with, file_content_before) 

205 

206 if file_content_before == file_content_after and current_version.original: 206 ↛ 207line 206 didn't jump to line 207, because the condition on line 206 was never true

207 og_context = deepcopy(context) 

208 og_context["current_version"] = current_version.original 

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

210 file_content_after = search_for_og.sub(replace_with, file_content_before) 

211 

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

213 logger.dedent() 

214 if not dry_run: # pragma: no-coverage 214 ↛ exitline 214 didn't return from function 'make_file_change', because the condition on line 214 was never false

215 self.write_file_contents(file_content_after) 

216 

217 def _get_serialized_version(self, context_key: str, version: Version, context: MutableMapping) -> str: 

218 """Get the serialized version.""" 

219 logger.debug("Serializing the %s", context_key.replace("_", " ")) 

220 logger.indent() 

221 serialized_version = self.version_config.serialize(version, context) 

222 logger.dedent() 

223 return serialized_version 

224 

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

226 return self.file_change.filename 

227 

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

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

230 

231 

232def resolve_file_config( 

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

234) -> List[ConfiguredFile]: 

235 """ 

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

237 

238 Args: 

239 files: A list of file configurations 

240 version_config: How the version should be changed 

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

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

243 

244 Returns: 

245 A list of ConfiguredFiles 

246 """ 

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

248 

249 

250def modify_files( 

251 files: List[ConfiguredFile], 

252 current_version: Version, 

253 new_version: Version, 

254 context: MutableMapping, 

255 dry_run: bool = False, 

256) -> None: 

257 """ 

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

259 

260 Args: 

261 files: The list of configured files 

262 current_version: The current version 

263 new_version: The next version 

264 context: The context used for rendering the version 

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

266 """ 

267 # _check_files_contain_version(files, current_version, context) 

268 for f in files: 

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

270 

271 

272class FileUpdater: 

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

274 

275 def __init__( 

276 self, 

277 file_change: FileChange, 

278 version_config: VersionConfig, 

279 search: Optional[str] = None, 

280 replace: Optional[str] = None, 

281 ) -> None: 

282 self.file_change = FileChange( 

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

284 serialize=file_change.serialize or version_config.serialize_formats, 

285 search=search or file_change.search or version_config.search, 

286 replace=replace or file_change.replace or version_config.replace, 

287 regex=file_change.regex or False, 

288 ignore_missing_file=file_change.ignore_missing_file or False, 

289 ignore_missing_version=file_change.ignore_missing_version or False, 

290 filename=file_change.filename, 

291 glob=file_change.glob, 

292 key_path=file_change.key_path, 

293 ) 

294 self.version_config = VersionConfig( 

295 self.file_change.parse, 

296 self.file_change.serialize, 

297 self.file_change.search, 

298 self.file_change.replace, 

299 version_config.part_configs, 

300 ) 

301 self._newlines: Optional[str] = None 

302 

303 def update_file( 

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

305 ) -> None: 

306 """Update the files.""" 

307 # TODO: Implement this 

308 pass 

309 

310 

311class DataFileUpdater: 

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

313 

314 def __init__( 

315 self, 

316 file_change: FileChange, 

317 version_part_configs: Dict[str, VersionComponentSpec], 

318 ) -> None: 

319 self.file_change = file_change 

320 self.version_config = VersionConfig( 

321 self.file_change.parse, 

322 self.file_change.serialize, 

323 self.file_change.search, 

324 self.file_change.replace, 

325 version_part_configs, 

326 ) 

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

328 self._newlines: Optional[str] = None 

329 

330 def update_file( 

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

332 ) -> None: 

333 """Update the files.""" 

334 new_context = deepcopy(context) 

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

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

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

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

339 if self.path.suffix == ".toml": 339 ↛ exitline 339 didn't return from function 'update_file', because the condition on line 339 was never false

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

341 

342 def _update_toml_file( 

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

344 ) -> None: 

345 """Update a TOML file.""" 

346 import tomlkit 

347 

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

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

350 

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

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

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

354 raise ValueError( 

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

356 f"{raw_search_pattern}" 

357 ) 

358 

359 new_value = search_for.sub(replace_with, value_before) 

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

361 

362 if dry_run: 362 ↛ 363line 362 didn't jump to line 363, because the condition on line 362 was never true

363 return 

364 

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

366 

367 self.path.write_text(tomlkit.dumps(toml_data), encoding="utf-8")