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
« 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
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
13logger = get_indented_logger(__name__)
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
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
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.
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
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)
66class ConfiguredFile:
67 """A file to modify in a configured way."""
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
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
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()
108 with open(self.file_change.filename, "wt", encoding="utf-8", newline=self._newlines) as f:
109 f.write(contents)
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?
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
123 Raises:
124 VersionNotFoundError: if the version number isn't present in this file.
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
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.
137 # check whether `search` isn't customized
138 search_pattern_is_default = self.file_change.search == self.version_config.search
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
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}'")
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()
172 search_for, raw_search_pattern = self.file_change.get_search_pattern(context)
173 replace_with = self.version_config.replace.format(**context)
175 if not self._contains_change_pattern(search_for, raw_search_pattern, current_version, context):
176 return
178 file_content_before = self.get_file_contents()
180 file_content_after = search_for.sub(replace_with, file_content_before)
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)
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)
193 def __str__(self) -> str: # pragma: no-coverage
194 return self.file_change.filename
196 def __repr__(self) -> str: # pragma: no-coverage
197 return f"<bumpversion.ConfiguredFile:{self.file_change.filename}>"
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.
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
212 Returns:
213 A list of ConfiguredFiles
214 """
215 return [ConfiguredFile(file_cfg, version_config, search, replace) for file_cfg in files]
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.
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)
240class FileUpdater:
241 """A class to handle updating files."""
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
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
278class DataFileUpdater:
279 """A class to handle updating files."""
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
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)
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
316 toml_data = tomlkit.parse(self.path.read_text())
317 value_before = dotted.get(toml_data, self.file_change.key_path)
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 )
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)
330 if dry_run:
331 return
333 dotted.update(toml_data, self.file_change.key_path, new_value)
334 self.path.write_text(tomlkit.dumps(toml_data))