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

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

2

3 import os.path

4 import re

5 from copy import deepcopy

6 from difflib import context_diff

7 from pathlib import Path

8 from typing import Dict, List, MutableMapping, Optional

9

10 from bumpversion.config.models import FileChange

11 from bumpversion.exceptions import VersionNotFoundError

12 from bumpversion.ui import get_indented_logger

13 from bumpversion.utils import get_nested_value, set_nested_value

14 from bumpversion.version_part import VersionConfig

15 from bumpversion.versioning.models import Version, VersionComponentSpec

16

17 logger = get_indented_logger(__name__)

18

19

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

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

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

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

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

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

306 class 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))