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
« 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
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
14logger = get_indented_logger(__name__)
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
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
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.
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
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)
67class ConfiguredFile:
68 """A file to modify in a configured way."""
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
97 def get_file_contents(self) -> str:
98 """
99 Return the contents of the file.
101 Raises:
102 FileNotFoundError: if the file doesn't exist
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}'")
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
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()
120 with open(self.file_change.filename, "wt", encoding="utf-8", newline=self._newlines) as f:
121 f.write(contents)
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?
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
135 Raises:
136 VersionNotFoundError: if the version number isn't present in this file.
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
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.
149 # check whether `search` isn't customized
150 search_pattern_is_default = self.file_change.search == self.version_config.search
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
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}'")
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"]
187 search_for, raw_search_pattern = self.file_change.get_search_pattern(context)
188 replace_with = self.version_config.replace.format(**context)
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
193 file_content_before = self.get_file_contents()
195 file_content_after = search_for.sub(replace_with, file_content_before)
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)
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)
208 def __str__(self) -> str: # pragma: no-coverage
209 return self.file_change.filename
211 def __repr__(self) -> str: # pragma: no-coverage
212 return f"<bumpversion.ConfiguredFile:{self.file_change.filename}>"
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.
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
227 Returns:
228 A list of ConfiguredFiles
229 """
230 return [ConfiguredFile(file_cfg, version_config, search, replace) for file_cfg in files]
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.
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)
255class FileUpdater:
256 """A class to handle updating files."""
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
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
293class DataFileUpdater:
294 """A class to handle updating files."""
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
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)
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
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 )
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)
345 if dry_run: 345 ↛ 346line 345 didn't jump to line 346, because the condition on line 345 was never true
346 return
348 dotted.update(toml_data, self.file_change.key_path, new_value)
349 self.path.write_text(tomlkit.dumps(toml_data))