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

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

2 import os.path

3 import re

4 from copy import deepcopy

5 from difflib import context_diff

6 from pathlib import Path

7 from typing import Dict, List, MutableMapping, Optional

8

9 from bumpversion.config.models import FileChange, VersionPartConfig

10 from bumpversion.exceptions import VersionNotFoundError

11 from bumpversion.ui import get_indented_logger

12 from bumpversion.version_part import Version, VersionConfig

13

14 logger = get_indented_logger(__name__)

15

16

17 def 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): 22 ↛ 31line 22 didn't jump to line 31, because the loop on line 22 didn't complete

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

34 def 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: 44 ↛ 64line 44 didn't jump to line 64, because the condition on line 44 was never false

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

67 class 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 ↛ 179line 178 didn't jump to line 179, because the condition on line 178 was never true

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

215 def 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

233 def 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

255 class 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

293 class 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":

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

334 if value_before is None:

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:

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:

346 return

347

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

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