Skip to content

Commit e7a7629

Browse files
committed
Fixed regression regarding multiple changes in one file.
Changed the method of marking changes from a dict keyed by the file name to a list of FileChanges. FileChanges encapsulate a single change to a file.
1 parent d1d19e3 commit e7a7629

File tree

3 files changed

+71
-51
lines changed

3 files changed

+71
-51
lines changed

bumpversion/config/models.py

+22-12
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
"""Bump My Version configuration models."""
22
from __future__ import annotations
33

4-
import logging
54
import re
5+
from collections import defaultdict
6+
from itertools import chain
67
from typing import TYPE_CHECKING, Dict, List, MutableMapping, Optional, Tuple, Union
78

89
from pydantic import BaseModel, Field
910
from pydantic_settings import BaseSettings, SettingsConfigDict
1011

12+
from bumpversion.ui import get_indented_logger
13+
1114
if TYPE_CHECKING:
1215
from bumpversion.scm import SCMInfo
1316
from bumpversion.version_part import VersionConfig
1417

15-
logger = logging.getLogger(__name__)
18+
logger = get_indented_logger(__name__)
1619

1720

1821
class VersionPartConfig(BaseModel):
@@ -48,23 +51,29 @@ def get_search_pattern(self, context: MutableMapping) -> Tuple[re.Pattern, str]:
4851
Returns:
4952
A tuple of the compiled regex pattern and the raw pattern as a string.
5053
"""
54+
logger.debug("Rendering search pattern with context")
55+
logger.indent()
5156
# the default search pattern is escaped, so we can still use it in a regex
5257
raw_pattern = self.search.format(**context)
5358
default = re.compile(re.escape(raw_pattern), re.MULTILINE | re.DOTALL)
5459
if not self.regex:
5560
logger.debug("No RegEx flag detected. Searching for the default pattern: '%s'", default.pattern)
61+
logger.dedent()
5662
return default, raw_pattern
5763

5864
re_context = {key: re.escape(str(value)) for key, value in context.items()}
5965
regex_pattern = self.search.format(**re_context)
6066
try:
6167
search_for_re = re.compile(regex_pattern, re.MULTILINE | re.DOTALL)
6268
logger.debug("Searching for the regex: '%s'", search_for_re.pattern)
69+
logger.dedent()
6370
return search_for_re, raw_pattern
6471
except re.error as e:
6572
logger.error("Invalid regex '%s': %s.", default, e)
6673

6774
logger.debug("Invalid regex. Searching for the default pattern: '%s'", raw_pattern)
75+
logger.dedent()
76+
6877
return default, raw_pattern
6978

7079

@@ -97,8 +106,6 @@ def add_files(self, filename: Union[str, List[str]]) -> None:
97106
"""Add a filename to the list of files."""
98107
filenames = [filename] if isinstance(filename, str) else filename
99108
for name in filenames:
100-
if name in self.resolved_filemap:
101-
continue
102109
self.files.append(
103110
FileChange(
104111
filename=name,
@@ -114,29 +121,32 @@ def add_files(self, filename: Union[str, List[str]]) -> None:
114121
)
115122

116123
@property
117-
def resolved_filemap(self) -> Dict[str, FileChange]:
124+
def resolved_filemap(self) -> Dict[str, List[FileChange]]:
118125
"""Return a map of filenames to file configs, expanding any globs."""
119126
from bumpversion.config.utils import resolve_glob_files
120127

128+
output = defaultdict(list)
121129
new_files = []
122130
for file_cfg in self.files:
123131
if file_cfg.glob:
124132
new_files.extend(resolve_glob_files(file_cfg))
125133
else:
126134
new_files.append(file_cfg)
127135

128-
return {file_cfg.filename: file_cfg for file_cfg in new_files}
136+
for file_cfg in new_files:
137+
output[file_cfg.filename].append(file_cfg)
138+
return output
129139

130140
@property
131141
def files_to_modify(self) -> List[FileChange]:
132142
"""Return a list of files to modify."""
133-
files_not_excluded = [
134-
file_cfg.filename
135-
for file_cfg in self.resolved_filemap.values()
136-
if file_cfg.filename not in self.excluded_paths
137-
]
143+
files_not_excluded = [filename for filename in self.resolved_filemap if filename not in self.excluded_paths]
138144
inclusion_set = set(self.included_paths) | set(files_not_excluded)
139-
return [file_cfg for file_cfg in self.resolved_filemap.values() if file_cfg.filename in inclusion_set]
145+
return list(
146+
chain.from_iterable(
147+
file_cfg_list for key, file_cfg_list in self.resolved_filemap.items() if key in inclusion_set
148+
)
149+
)
140150

141151
@property
142152
def version_config(self) -> "VersionConfig":

bumpversion/files.py

+43-33
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
"""Methods for changing files."""
2-
import logging
32
import re
43
from copy import deepcopy
54
from difflib import context_diff
@@ -8,9 +7,10 @@
87

98
from bumpversion.config.models import FileChange, VersionPartConfig
109
from bumpversion.exceptions import VersionNotFoundError
10+
from bumpversion.ui import get_indented_logger
1111
from bumpversion.version_part import Version, VersionConfig
1212

13-
logger = logging.getLogger(__name__)
13+
logger = get_indented_logger(__name__)
1414

1515

1616
def contains_pattern(search: re.Pattern, contents: str) -> bool:
@@ -22,7 +22,7 @@ def contains_pattern(search: re.Pattern, contents: str) -> bool:
2222
line_no = contents.count("\n", 0, m.start(0)) + 1
2323
logger.info(
2424
"Found '%s' at line %s: %s",
25-
search,
25+
search.pattern,
2626
line_no,
2727
m.string[m.start() : m.end(0)],
2828
)
@@ -42,8 +42,11 @@ def log_changes(file_path: str, file_content_before: str, file_content_after: st
4242
"""
4343
if file_content_before != file_content_after:
4444
logger.info("%s file %s:", "Would change" if dry_run else "Changing", file_path)
45+
logger.indent()
46+
indent_str = logger.indent_str
47+
4548
logger.info(
46-
"\n".join(
49+
f"\n{indent_str}".join(
4750
list(
4851
context_diff(
4952
file_content_before.splitlines(),
@@ -53,8 +56,9 @@ def log_changes(file_path: str, file_content_before: str, file_content_after: st
5356
lineterm="",
5457
)
5558
)
56-
)
59+
),
5760
)
61+
logger.dedent()
5862
else:
5963
logger.info("%s file %s", "Would not change" if dry_run else "Not changing", file_path)
6064

@@ -104,12 +108,16 @@ def write_file_contents(self, contents: str) -> None:
104108
with open(self.file_change.filename, "wt", encoding="utf-8", newline=self._newlines) as f:
105109
f.write(contents)
106110

107-
def contains_version(self, version: Version, context: MutableMapping) -> bool:
111+
def _contains_change_pattern(
112+
self, search_expression: re.Pattern, raw_search_expression: str, version: Version, context: MutableMapping
113+
) -> bool:
108114
"""
109-
Check whether the version is present in the file.
115+
Does the file contain the change pattern?
110116
111117
Args:
112-
version: The version to check
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
113121
context: The context to use
114122
115123
Raises:
@@ -118,17 +126,15 @@ def contains_version(self, version: Version, context: MutableMapping) -> bool:
118126
Returns:
119127
True if the version number is in fact present.
120128
"""
121-
search_expression, raw_search_expression = self.file_change.get_search_pattern(context)
122129
file_contents = self.get_file_contents()
123130
if contains_pattern(search_expression, file_contents):
124131
return True
125132

126-
# the `search` pattern did not match, but the original supplied
133+
# The `search` pattern did not match, but the original supplied
127134
# version number (representing the same version part values) might
128-
# match instead.
135+
# match instead. This is probably the case if environment variables are used.
129136

130-
# check whether `search` isn't customized, i.e. should match only
131-
# very specific parts of the file
137+
# check whether `search` isn't customized
132138
search_pattern_is_default = self.file_change.search == self.version_config.search
133139

134140
if search_pattern_is_default and contains_pattern(re.compile(re.escape(version.original)), file_contents):
@@ -141,19 +147,36 @@ def contains_version(self, version: Version, context: MutableMapping) -> bool:
141147
return False
142148
raise VersionNotFoundError(f"Did not find '{raw_search_expression}' in file: '{self.file_change.filename}'")
143149

144-
def replace_version(
150+
def make_file_change(
145151
self, current_version: Version, new_version: Version, context: MutableMapping, dry_run: bool = False
146152
) -> None:
147-
"""Replace the current version with the new version."""
148-
file_content_before = self.get_file_contents()
149-
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()
150164
context["current_version"] = self.version_config.serialize(current_version, context)
165+
logger.dedent()
151166
if new_version:
167+
logger.debug("Serializing the new version")
168+
logger.indent()
152169
context["new_version"] = self.version_config.serialize(new_version, context)
170+
logger.dedent()
153171

154172
search_for, raw_search_pattern = self.file_change.get_search_pattern(context)
155173
replace_with = self.version_config.replace.format(**context)
156174

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+
157180
file_content_after = search_for.sub(replace_with, file_content_before)
158181

159182
if file_content_before == file_content_after and current_version.original:
@@ -163,7 +186,7 @@ def replace_version(
163186
file_content_after = search_for_og.sub(replace_with, file_content_before)
164187

165188
log_changes(self.file_change.filename, file_content_before, file_content_after, dry_run)
166-
189+
logger.dedent()
167190
if not dry_run: # pragma: no-coverage
168191
self.write_file_contents(file_content_after)
169192

@@ -209,22 +232,9 @@ def modify_files(
209232
context: The context used for rendering the version
210233
dry_run: True if this should be a report-only job
211234
"""
212-
_check_files_contain_version(files, current_version, context)
213-
for f in files:
214-
f.replace_version(current_version, new_version, context, dry_run)
215-
216-
217-
def _check_files_contain_version(
218-
files: List[ConfiguredFile], current_version: Version, context: MutableMapping
219-
) -> None:
220-
"""Make sure files exist and contain version string."""
221-
logger.info(
222-
"Asserting files %s contain the version string...",
223-
", ".join({str(f.file_change.filename) for f in files}),
224-
)
235+
# _check_files_contain_version(files, current_version, context)
225236
for f in files:
226-
context["current_version"] = f.version_config.serialize(current_version, context)
227-
f.contains_version(current_version, context)
237+
f.make_file_change(current_version, new_version, context, dry_run)
228238

229239

230240
class FileUpdater:

tests/test_files.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def test_single_file_processed_twice(tmp_path: Path):
5353
assert len(conf.files) == 2
5454
for file_cfg in conf.files:
5555
cfg_file = files.ConfiguredFile(file_cfg, version_config)
56-
cfg_file.replace_version(current_version, new_version, ctx)
56+
cfg_file.make_file_change(current_version, new_version, ctx)
5757

5858
assert filepath.read_text() == "dots: 0.10.3\ndashes: 0-10-3"
5959

@@ -106,7 +106,7 @@ def test_multi_file_configuration(tmp_path: Path):
106106

107107
for file_cfg in conf.files:
108108
cfg_file = files.ConfiguredFile(file_cfg, version_config)
109-
cfg_file.replace_version(current_version, major_version, ctx)
109+
cfg_file.make_file_change(current_version, major_version, ctx)
110110

111111
assert full_vers_path.read_text() == "2.0.0"
112112
assert maj_vers_path.read_text() == "2"
@@ -123,7 +123,7 @@ def test_multi_file_configuration(tmp_path: Path):
123123
major_patch_version = major_version.bump("patch", version_config.order)
124124
for file_cfg in conf.files:
125125
cfg_file = files.ConfiguredFile(file_cfg, version_config)
126-
cfg_file.replace_version(major_version, major_patch_version, ctx)
126+
cfg_file.make_file_change(major_version, major_patch_version, ctx)
127127

128128
assert full_vers_path.read_text() == "2.0.1"
129129
assert maj_vers_path.read_text() == "2"
@@ -220,7 +220,7 @@ def test_search_replace_to_avoid_updating_unconcerned_lines(tmp_path: Path, capl
220220

221221
for file_cfg in conf.files:
222222
cfg_file = files.ConfiguredFile(file_cfg, version_config)
223-
cfg_file.replace_version(current_version, new_version, get_context(conf))
223+
cfg_file.make_file_change(current_version, new_version, get_context(conf))
224224

225225
utc_today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
226226
expected_chglog = dedent(
@@ -291,7 +291,7 @@ def test_simple_replacement_in_utf8_file(tmp_path: Path):
291291
# Act
292292
for file_cfg in conf.files:
293293
cfg_file = files.ConfiguredFile(file_cfg, version_config)
294-
cfg_file.replace_version(current_version, new_version, get_context(conf))
294+
cfg_file.make_file_change(current_version, new_version, get_context(conf))
295295

296296
# Assert
297297
out = version_path.read_text()
@@ -317,7 +317,7 @@ def test_multi_line_search_is_found(tmp_path: Path) -> None:
317317
# Act
318318
for file_cfg in conf.files:
319319
cfg_file = files.ConfiguredFile(file_cfg, version_config)
320-
cfg_file.replace_version(current_version, new_version, get_context(conf))
320+
cfg_file.make_file_change(current_version, new_version, get_context(conf))
321321

322322
# Assert
323323
assert alphabet_path.read_text() == "A\nB\nC\n10.0.0\n"

0 commit comments

Comments
 (0)