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

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

2 import re

3 from copy import deepcopy

4 from difflib import context_diff

5 from pathlib import Path

6 from typing import Dict, List, MutableMapping, Optional

7

8 from bumpversion.config.models import FileChange, VersionPartConfig

9 from bumpversion.exceptions import VersionNotFoundError

10 from bumpversion.ui import get_indented_logger

11 from bumpversion.version_part import Version, VersionConfig

12

13 logger = get_indented_logger(__name__)

14

15

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

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

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

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

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

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

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