Coverage for /Users/coordt/Documents/code/bump-my-version/bumpversion/files.py: 72%
138 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-06-12 09:28 -0500
« prev ^ index » next coverage.py v7.4.4, created at 2024-06-12 09:28 -0500
1"""Methods for changing files."""
3import os.path
4import re
5from copy import deepcopy
6from difflib import context_diff
7from pathlib import Path
8from typing import Dict, List, MutableMapping, Optional
10from bumpversion.config.models import FileChange
11from bumpversion.exceptions import VersionNotFoundError
12from bumpversion.ui import get_indented_logger
13from bumpversion.utils import get_nested_value, set_nested_value
14from bumpversion.version_part import VersionConfig
15from bumpversion.versioning.models import Version, VersionComponentSpec
17logger = get_indented_logger(__name__)
20def 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
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
37def 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.
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
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)
70class ConfiguredFile:
71 """A file to modify in a configured way."""
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 include_bumps=file_change.include_bumps,
94 exclude_bumps=file_change.exclude_bumps,
95 )
96 self.version_config = VersionConfig(
97 self.file_change.parse,
98 self.file_change.serialize,
99 self.file_change.search,
100 self.file_change.replace,
101 version_config.part_configs,
102 )
103 self._newlines: Optional[str] = None
105 def get_file_contents(self) -> str:
106 """
107 Return the contents of the file.
109 Raises:
110 FileNotFoundError: if the file doesn't exist
112 Returns:
113 The contents of the file
114 """
115 if not os.path.exists(self.file_change.filename): 115 ↛ 116line 115 didn't jump to line 116, because the condition on line 115 was never true
116 raise FileNotFoundError(f"File not found: '{self.file_change.filename}'") # pragma: no-coverage
118 with open(self.file_change.filename, "rt", encoding="utf-8") as f:
119 contents = f.read()
120 self._newlines = f.newlines[0] if isinstance(f.newlines, tuple) else f.newlines
121 return contents
123 def write_file_contents(self, contents: str) -> None:
124 """Write the contents of the file."""
125 if self._newlines is None: 125 ↛ 126line 125 didn't jump to line 126, because the condition on line 125 was never true
126 _ = self.get_file_contents()
128 with open(self.file_change.filename, "wt", encoding="utf-8", newline=self._newlines) as f:
129 f.write(contents)
131 def _contains_change_pattern(
132 self, search_expression: re.Pattern, raw_search_expression: str, version: Version, context: MutableMapping
133 ) -> bool:
134 """
135 Does the file contain the change pattern?
137 Args:
138 search_expression: The compiled search expression
139 raw_search_expression: The raw search expression
140 version: The version to check, in case it's not the same as the original
141 context: The context to use
143 Raises:
144 VersionNotFoundError: if the version number isn't present in this file.
146 Returns:
147 True if the version number is in fact present.
148 """
149 file_contents = self.get_file_contents()
150 if contains_pattern(search_expression, file_contents): 150 ↛ 158line 150 didn't jump to line 158, because the condition on line 150 was never false
151 return True
153 # The `search` pattern did not match, but the original supplied
154 # version number (representing the same version part values) might
155 # match instead. This is probably the case if environment variables are used.
157 # check whether `search` isn't customized
158 search_pattern_is_default = self.file_change.search == self.version_config.search
160 if search_pattern_is_default and contains_pattern(re.compile(re.escape(version.original)), file_contents):
161 # The original version is present, and we're not looking for something
162 # more specific -> this is accepted as a match
163 return True
165 # version not found
166 if self.file_change.ignore_missing_version:
167 return False
168 raise VersionNotFoundError(f"Did not find '{raw_search_expression}' in file: '{self.file_change.filename}'")
170 def make_file_change(
171 self, current_version: Version, new_version: Version, context: MutableMapping, dry_run: bool = False
172 ) -> None:
173 """Make the change to the file."""
174 logger.info(
175 "\n%sFile %s: replace `%s` with `%s`",
176 logger.indent_str,
177 self.file_change.filename,
178 self.file_change.search,
179 self.file_change.replace,
180 )
181 logger.indent()
182 if not os.path.exists(self.file_change.filename): 182 ↛ 183line 182 didn't jump to line 183, because the condition on line 182 was never true
183 if self.file_change.ignore_missing_file:
184 logger.info("File not found, but ignoring")
185 logger.dedent()
186 return
187 raise FileNotFoundError(f"File not found: '{self.file_change.filename}'") # pragma: no-coverage
188 context["current_version"] = self._get_serialized_version("current_version", current_version, context)
189 if new_version: 189 ↛ 192line 189 didn't jump to line 192, because the condition on line 189 was never false
190 context["new_version"] = self._get_serialized_version("new_version", new_version, context)
191 else:
192 logger.debug("No new version, using current version as new version")
193 context["new_version"] = context["current_version"]
195 search_for, raw_search_pattern = self.file_change.get_search_pattern(context)
196 replace_with = self.version_config.replace.format(**context)
198 if not self._contains_change_pattern(search_for, raw_search_pattern, current_version, context): 198 ↛ 199line 198 didn't jump to line 199, because the condition on line 198 was never true
199 logger.dedent()
200 return
202 file_content_before = self.get_file_contents()
204 file_content_after = search_for.sub(replace_with, file_content_before)
206 if file_content_before == file_content_after and current_version.original: 206 ↛ 207line 206 didn't jump to line 207, because the condition on line 206 was never true
207 og_context = deepcopy(context)
208 og_context["current_version"] = current_version.original
209 search_for_og, og_raw_search_pattern = self.file_change.get_search_pattern(og_context)
210 file_content_after = search_for_og.sub(replace_with, file_content_before)
212 log_changes(self.file_change.filename, file_content_before, file_content_after, dry_run)
213 logger.dedent()
214 if not dry_run: # pragma: no-coverage 214 ↛ exitline 214 didn't return from function 'make_file_change', because the condition on line 214 was never false
215 self.write_file_contents(file_content_after)
217 def _get_serialized_version(self, context_key: str, version: Version, context: MutableMapping) -> str:
218 """Get the serialized version."""
219 logger.debug("Serializing the %s", context_key.replace("_", " "))
220 logger.indent()
221 serialized_version = self.version_config.serialize(version, context)
222 logger.dedent()
223 return serialized_version
225 def __str__(self) -> str: # pragma: no-coverage
226 return self.file_change.filename
228 def __repr__(self) -> str: # pragma: no-coverage
229 return f"<bumpversion.ConfiguredFile:{self.file_change.filename}>"
232def resolve_file_config(
233 files: List[FileChange], version_config: VersionConfig, search: Optional[str] = None, replace: Optional[str] = None
234) -> List[ConfiguredFile]:
235 """
236 Resolve the files, searching and replacing values according to the FileConfig.
238 Args:
239 files: A list of file configurations
240 version_config: How the version should be changed
241 search: The search pattern to use instead of any configured search pattern
242 replace: The replace pattern to use instead of any configured replace pattern
244 Returns:
245 A list of ConfiguredFiles
246 """
247 return [ConfiguredFile(file_cfg, version_config, search, replace) for file_cfg in files]
250def modify_files(
251 files: List[ConfiguredFile],
252 current_version: Version,
253 new_version: Version,
254 context: MutableMapping,
255 dry_run: bool = False,
256) -> None:
257 """
258 Modify the files, searching and replacing values according to the FileConfig.
260 Args:
261 files: The list of configured files
262 current_version: The current version
263 new_version: The next version
264 context: The context used for rendering the version
265 dry_run: True if this should be a report-only job
266 """
267 # _check_files_contain_version(files, current_version, context)
268 for f in files:
269 f.make_file_change(current_version, new_version, context, dry_run)
272class FileUpdater:
273 """A class to handle updating files."""
275 def __init__(
276 self,
277 file_change: FileChange,
278 version_config: VersionConfig,
279 search: Optional[str] = None,
280 replace: Optional[str] = None,
281 ) -> None:
282 self.file_change = FileChange(
283 parse=file_change.parse or version_config.parse_regex.pattern,
284 serialize=file_change.serialize or version_config.serialize_formats,
285 search=search or file_change.search or version_config.search,
286 replace=replace or file_change.replace or version_config.replace,
287 regex=file_change.regex or False,
288 ignore_missing_file=file_change.ignore_missing_file or False,
289 ignore_missing_version=file_change.ignore_missing_version or False,
290 filename=file_change.filename,
291 glob=file_change.glob,
292 key_path=file_change.key_path,
293 )
294 self.version_config = VersionConfig(
295 self.file_change.parse,
296 self.file_change.serialize,
297 self.file_change.search,
298 self.file_change.replace,
299 version_config.part_configs,
300 )
301 self._newlines: Optional[str] = None
303 def update_file(
304 self, current_version: Version, new_version: Version, context: MutableMapping, dry_run: bool = False
305 ) -> None:
306 """Update the files."""
307 # TODO: Implement this
308 pass
311class DataFileUpdater:
312 """A class to handle updating files."""
314 def __init__(
315 self,
316 file_change: FileChange,
317 version_part_configs: Dict[str, VersionComponentSpec],
318 ) -> None:
319 self.file_change = file_change
320 self.version_config = VersionConfig(
321 self.file_change.parse,
322 self.file_change.serialize,
323 self.file_change.search,
324 self.file_change.replace,
325 version_part_configs,
326 )
327 self.path = Path(self.file_change.filename)
328 self._newlines: Optional[str] = None
330 def update_file(
331 self, current_version: Version, new_version: Version, context: MutableMapping, dry_run: bool = False
332 ) -> None:
333 """Update the files."""
334 new_context = deepcopy(context)
335 new_context["current_version"] = self.version_config.serialize(current_version, context)
336 new_context["new_version"] = self.version_config.serialize(new_version, context)
337 search_for, raw_search_pattern = self.file_change.get_search_pattern(new_context)
338 replace_with = self.file_change.replace.format(**new_context)
339 if self.path.suffix == ".toml": 339 ↛ exitline 339 didn't return from function 'update_file', because the condition on line 339 was never false
340 self._update_toml_file(search_for, raw_search_pattern, replace_with, dry_run)
342 def _update_toml_file(
343 self, search_for: re.Pattern, raw_search_pattern: str, replace_with: str, dry_run: bool = False
344 ) -> None:
345 """Update a TOML file."""
346 import tomlkit
348 toml_data = tomlkit.parse(self.path.read_text(encoding="utf-8"))
349 value_before = get_nested_value(toml_data, self.file_change.key_path)
351 if value_before is None: 351 ↛ 352line 351 didn't jump to line 352, because the condition on line 351 was never true
352 raise KeyError(f"Key path '{self.file_change.key_path}' does not exist in {self.path}")
353 elif not contains_pattern(search_for, value_before) and not self.file_change.ignore_missing_version: 353 ↛ 354line 353 didn't jump to line 354, because the condition on line 353 was never true
354 raise ValueError(
355 f"Key '{self.file_change.key_path}' in {self.path} does not contain the correct contents: "
356 f"{raw_search_pattern}"
357 )
359 new_value = search_for.sub(replace_with, value_before)
360 log_changes(f"{self.path}:{self.file_change.key_path}", value_before, new_value, dry_run)
362 if dry_run: 362 ↛ 363line 362 didn't jump to line 363, because the condition on line 362 was never true
363 return
365 set_nested_value(toml_data, new_value, self.file_change.key_path)
367 self.path.write_text(tomlkit.dumps(toml_data), encoding="utf-8")