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

123 statements  

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

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

2import re 

3from copy import deepcopy 

4from difflib import context_diff 

5from pathlib import Path 

6from typing import Dict, List, MutableMapping, Optional 

7 

8from bumpversion.config.models import FileChange, VersionPartConfig 

9from bumpversion.exceptions import VersionNotFoundError 

10from bumpversion.ui import get_indented_logger 

11from bumpversion.version_part import Version, VersionConfig 

12 

13logger = get_indented_logger(__name__) 

14 

15 

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

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

18 if not search or not contents: 

19 return False 

20 

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

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

23 logger.info( 

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

25 search.pattern, 

26 line_no, 

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

28 ) 

29 return True 

30 return False 

31 

32 

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

34 """ 

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

36 

37 Args: 

38 file_path: The path to the file 

39 file_content_before: The file contents before the change 

40 file_content_after: The file contents after the change 

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

42 """ 

43 if file_content_before != file_content_after: 

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

45 logger.indent() 

46 indent_str = logger.indent_str 

47 

48 logger.info( 

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

50 list( 

51 context_diff( 

52 file_content_before.splitlines(), 

53 file_content_after.splitlines(), 

54 fromfile=f"before {file_path}", 

55 tofile=f"after {file_path}", 

56 lineterm="", 

57 ) 

58 ) 

59 ), 

60 ) 

61 logger.dedent() 

62 else: 

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

64 

65 

66class ConfiguredFile: 

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

68 

69 def __init__( 

70 self, 

71 file_change: FileChange, 

72 version_config: VersionConfig, 

73 search: Optional[str] = None, 

74 replace: Optional[str] = None, 

75 ) -> None: 

76 self.file_change = FileChange( 

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

78 serialize=file_change.serialize or version_config.serialize_formats, 

79 search=search or file_change.search or version_config.search, 

80 replace=replace or file_change.replace or version_config.replace, 

81 regex=file_change.regex or False, 

82 ignore_missing_version=file_change.ignore_missing_version or False, 

83 filename=file_change.filename, 

84 glob=file_change.glob, 

85 key_path=file_change.key_path, 

86 ) 

87 self.version_config = VersionConfig( 

88 self.file_change.parse, 

89 self.file_change.serialize, 

90 self.file_change.search, 

91 self.file_change.replace, 

92 version_config.part_configs, 

93 ) 

94 self._newlines: Optional[str] = None 

95 

96 def get_file_contents(self) -> str: 

97 """Return the contents of the file.""" 

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

99 contents = f.read() 

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

101 return contents 

102 

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

104 """Write the contents of the file.""" 

105 if self._newlines is None: 

106 _ = self.get_file_contents() 

107 

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

109 f.write(contents) 

110 

111 def _contains_change_pattern( 

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

113 ) -> bool: 

114 """ 

115 Does the file contain the change pattern? 

116 

117 Args: 

118 search_expression: The compiled search expression 

119 raw_search_expression: The raw search expression 

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

121 context: The context to use 

122 

123 Raises: 

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

125 

126 Returns: 

127 True if the version number is in fact present. 

128 """ 

129 file_contents = self.get_file_contents() 

130 if contains_pattern(search_expression, file_contents): 

131 return True 

132 

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

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

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

136 

137 # check whether `search` isn't customized 

138 search_pattern_is_default = self.file_change.search == self.version_config.search 

139 

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

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

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

143 return True 

144 

145 # version not found 

146 if self.file_change.ignore_missing_version: 

147 return False 

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

149 

150 def make_file_change( 

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

152 ) -> None: 

153 """Make the change to the file.""" 

154 logger.info( 

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

156 logger.indent_str, 

157 self.file_change.filename, 

158 self.file_change.search, 

159 self.file_change.replace, 

160 ) 

161 logger.indent() 

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

163 logger.indent() 

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

165 logger.dedent() 

166 if new_version: 

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

168 logger.indent() 

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

170 logger.dedent() 

171 

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

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

174 

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

176 return 

177 

178 file_content_before = self.get_file_contents() 

179 

180 file_content_after = search_for.sub(replace_with, file_content_before) 

181 

182 if file_content_before == file_content_after and current_version.original: 

183 og_context = deepcopy(context) 

184 og_context["current_version"] = current_version.original 

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

186 file_content_after = search_for_og.sub(replace_with, file_content_before) 

187 

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

189 logger.dedent() 

190 if not dry_run: # pragma: no-coverage 

191 self.write_file_contents(file_content_after) 

192 

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

194 return self.file_change.filename 

195 

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

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

198 

199 

200def resolve_file_config( 

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

202) -> List[ConfiguredFile]: 

203 """ 

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

205 

206 Args: 

207 files: A list of file configurations 

208 version_config: How the version should be changed 

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

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

211 

212 Returns: 

213 A list of ConfiguredFiles 

214 """ 

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

216 

217 

218def modify_files( 

219 files: List[ConfiguredFile], 

220 current_version: Version, 

221 new_version: Version, 

222 context: MutableMapping, 

223 dry_run: bool = False, 

224) -> None: 

225 """ 

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

227 

228 Args: 

229 files: The list of configured files 

230 current_version: The current version 

231 new_version: The next version 

232 context: The context used for rendering the version 

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

234 """ 

235 # _check_files_contain_version(files, current_version, context) 

236 for f in files: 

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

238 

239 

240class FileUpdater: 

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

242 

243 def __init__( 

244 self, 

245 file_change: FileChange, 

246 version_config: VersionConfig, 

247 search: Optional[str] = None, 

248 replace: Optional[str] = None, 

249 ) -> None: 

250 self.file_change = FileChange( 

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

252 serialize=file_change.serialize or version_config.serialize_formats, 

253 search=search or file_change.search or version_config.search, 

254 replace=replace or file_change.replace or version_config.replace, 

255 regex=file_change.regex or False, 

256 ignore_missing_version=file_change.ignore_missing_version or False, 

257 filename=file_change.filename, 

258 glob=file_change.glob, 

259 key_path=file_change.key_path, 

260 ) 

261 self.version_config = VersionConfig( 

262 self.file_change.parse, 

263 self.file_change.serialize, 

264 self.file_change.search, 

265 self.file_change.replace, 

266 version_config.part_configs, 

267 ) 

268 self._newlines: Optional[str] = None 

269 

270 def update_file( 

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

272 ) -> None: 

273 """Update the files.""" 

274 # TODO: Implement this 

275 pass 

276 

277 

278class DataFileUpdater: 

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

280 

281 def __init__( 

282 self, 

283 file_change: FileChange, 

284 version_part_configs: Dict[str, VersionPartConfig], 

285 ) -> None: 

286 self.file_change = file_change 

287 self.version_config = VersionConfig( 

288 self.file_change.parse, 

289 self.file_change.serialize, 

290 self.file_change.search, 

291 self.file_change.replace, 

292 version_part_configs, 

293 ) 

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

295 self._newlines: Optional[str] = None 

296 

297 def update_file( 

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

299 ) -> None: 

300 """Update the files.""" 

301 new_context = deepcopy(context) 

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

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

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

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

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

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

308 

309 def _update_toml_file( 

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

311 ) -> None: 

312 """Update a TOML file.""" 

313 import dotted 

314 import tomlkit 

315 

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

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

318 

319 if value_before is None: 

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

321 elif not contains_pattern(search_for, value_before) and not self.file_change.ignore_missing_version: 

322 raise ValueError( 

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

324 f"{raw_search_pattern}" 

325 ) 

326 

327 new_value = search_for.sub(replace_with, value_before) 

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

329 

330 if dry_run: 

331 return 

332 

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

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