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

129 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-12-18 15:08 -0600

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

2import os.path 

3import re 

4from copy import deepcopy 

5from difflib import context_diff 

6from pathlib import Path 

7from typing import Dict, List, MutableMapping, Optional 

8 

9from bumpversion.config.models import FileChange, VersionPartConfig 

10from bumpversion.exceptions import VersionNotFoundError 

11from bumpversion.ui import get_indented_logger 

12from bumpversion.version_part import Version, VersionConfig 

13 

14logger = get_indented_logger(__name__) 

15 

16 

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

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

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

20 return False 

21 

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

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

24 logger.info( 

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

26 search.pattern, 

27 line_no, 

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

29 ) 

30 return True 

31 return False 

32 

33 

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

35 """ 

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

37 

38 Args: 

39 file_path: The path to the file 

40 file_content_before: The file contents before the change 

41 file_content_after: The file contents after the change 

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

43 """ 

44 if file_content_before != file_content_after: 

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

46 logger.indent() 

47 indent_str = logger.indent_str 

48 

49 logger.info( 

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

51 list( 

52 context_diff( 

53 file_content_before.splitlines(), 

54 file_content_after.splitlines(), 

55 fromfile=f"before {file_path}", 

56 tofile=f"after {file_path}", 

57 lineterm="", 

58 ) 

59 ) 

60 ), 

61 ) 

62 logger.dedent() 

63 else: 

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

65 

66 

67class ConfiguredFile: 

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

69 

70 def __init__( 

71 self, 

72 file_change: FileChange, 

73 version_config: VersionConfig, 

74 search: Optional[str] = None, 

75 replace: Optional[str] = None, 

76 ) -> None: 

77 self.file_change = FileChange( 

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

79 serialize=file_change.serialize or version_config.serialize_formats, 

80 search=search or file_change.search or version_config.search, 

81 replace=replace or file_change.replace or version_config.replace, 

82 regex=file_change.regex or False, 

83 ignore_missing_version=file_change.ignore_missing_version or False, 

84 filename=file_change.filename, 

85 glob=file_change.glob, 

86 key_path=file_change.key_path, 

87 ) 

88 self.version_config = VersionConfig( 

89 self.file_change.parse, 

90 self.file_change.serialize, 

91 self.file_change.search, 

92 self.file_change.replace, 

93 version_config.part_configs, 

94 ) 

95 self._newlines: Optional[str] = None 

96 

97 def get_file_contents(self) -> str: 

98 """ 

99 Return the contents of the file. 

100 

101 Raises: 

102 FileNotFoundError: if the file doesn't exist 

103 

104 Returns: 

105 The contents of the file 

106 """ 

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

108 raise FileNotFoundError(f"File not found: '{self.file_change.filename}'") 

109 

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

111 contents = f.read() 

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

113 return contents 

114 

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

116 """Write the contents of the file.""" 

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

118 _ = self.get_file_contents() 

119 

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

121 f.write(contents) 

122 

123 def _contains_change_pattern( 

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

125 ) -> bool: 

126 """ 

127 Does the file contain the change pattern? 

128 

129 Args: 

130 search_expression: The compiled search expression 

131 raw_search_expression: The raw search expression 

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

133 context: The context to use 

134 

135 Raises: 

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

137 

138 Returns: 

139 True if the version number is in fact present. 

140 """ 

141 file_contents = self.get_file_contents() 

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

143 return True 

144 

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

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

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

148 

149 # check whether `search` isn't customized 

150 search_pattern_is_default = self.file_change.search == self.version_config.search 

151 

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

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

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

155 return True 

156 

157 # version not found 

158 if self.file_change.ignore_missing_version: 

159 return False 

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

161 

162 def make_file_change( 

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

164 ) -> None: 

165 """Make the change to the file.""" 

166 logger.info( 

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

168 logger.indent_str, 

169 self.file_change.filename, 

170 self.file_change.search, 

171 self.file_change.replace, 

172 ) 

173 logger.indent() 

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

175 logger.indent() 

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

177 logger.dedent() 

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

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

180 logger.indent() 

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

182 logger.dedent() 

183 else: 

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

185 context["new_version"] = context["current_version"] 

186 

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

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

189 

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

191 return 

192 

193 file_content_before = self.get_file_contents() 

194 

195 file_content_after = search_for.sub(replace_with, file_content_before) 

196 

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

198 og_context = deepcopy(context) 

199 og_context["current_version"] = current_version.original 

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

201 file_content_after = search_for_og.sub(replace_with, file_content_before) 

202 

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

204 logger.dedent() 

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

206 self.write_file_contents(file_content_after) 

207 

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

209 return self.file_change.filename 

210 

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

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

213 

214 

215def resolve_file_config( 

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

217) -> List[ConfiguredFile]: 

218 """ 

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

220 

221 Args: 

222 files: A list of file configurations 

223 version_config: How the version should be changed 

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

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

226 

227 Returns: 

228 A list of ConfiguredFiles 

229 """ 

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

231 

232 

233def modify_files( 

234 files: List[ConfiguredFile], 

235 current_version: Version, 

236 new_version: Version, 

237 context: MutableMapping, 

238 dry_run: bool = False, 

239) -> None: 

240 """ 

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

242 

243 Args: 

244 files: The list of configured files 

245 current_version: The current version 

246 new_version: The next version 

247 context: The context used for rendering the version 

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

249 """ 

250 # _check_files_contain_version(files, current_version, context) 

251 for f in files: 

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

253 

254 

255class FileUpdater: 

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

257 

258 def __init__( 

259 self, 

260 file_change: FileChange, 

261 version_config: VersionConfig, 

262 search: Optional[str] = None, 

263 replace: Optional[str] = None, 

264 ) -> None: 

265 self.file_change = FileChange( 

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

267 serialize=file_change.serialize or version_config.serialize_formats, 

268 search=search or file_change.search or version_config.search, 

269 replace=replace or file_change.replace or version_config.replace, 

270 regex=file_change.regex or False, 

271 ignore_missing_version=file_change.ignore_missing_version or False, 

272 filename=file_change.filename, 

273 glob=file_change.glob, 

274 key_path=file_change.key_path, 

275 ) 

276 self.version_config = VersionConfig( 

277 self.file_change.parse, 

278 self.file_change.serialize, 

279 self.file_change.search, 

280 self.file_change.replace, 

281 version_config.part_configs, 

282 ) 

283 self._newlines: Optional[str] = None 

284 

285 def update_file( 

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

287 ) -> None: 

288 """Update the files.""" 

289 # TODO: Implement this 

290 pass 

291 

292 

293class DataFileUpdater: 

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

295 

296 def __init__( 

297 self, 

298 file_change: FileChange, 

299 version_part_configs: Dict[str, VersionPartConfig], 

300 ) -> None: 

301 self.file_change = file_change 

302 self.version_config = VersionConfig( 

303 self.file_change.parse, 

304 self.file_change.serialize, 

305 self.file_change.search, 

306 self.file_change.replace, 

307 version_part_configs, 

308 ) 

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

310 self._newlines: Optional[str] = None 

311 

312 def update_file( 

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

314 ) -> None: 

315 """Update the files.""" 

316 new_context = deepcopy(context) 

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

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

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

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

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

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

323 

324 def _update_toml_file( 

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

326 ) -> None: 

327 """Update a TOML file.""" 

328 import dotted 

329 import tomlkit 

330 

331 toml_data = tomlkit.parse(self.path.read_text()) 

332 value_before = dotted.get(toml_data, self.file_change.key_path) 

333 print(self.file_change.ignore_missing_version) 

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

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

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

337 raise ValueError( 

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

339 f"{raw_search_pattern}" 

340 ) 

341 

342 new_value = search_for.sub(replace_with, value_before) 

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

344 

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

346 return 

347 

348 dotted.update(toml_data, self.file_change.key_path, new_value) 

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