Skip to content

Commit a295a32

Browse files
committed
Added configuration and command-line no_regex option
- Global and individual file configurations available for `no_regex` - Command-line flag `--no-regex` flag added for `bump` and `replace` sub-commands
1 parent 0210d74 commit a295a32

8 files changed

+87
-54
lines changed

bumpversion/cli.py

+16
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,12 @@ def cli(ctx: Context) -> None:
139139
envvar="BUMPVERSION_REPLACE",
140140
help="Template for complete string to replace",
141141
)
142+
@click.option(
143+
"--no-regex",
144+
is_flag=True,
145+
envvar="BUMPVERSION_NO_REGEX",
146+
help="Do not treat the search parameter as a regular expression",
147+
)
142148
@click.option(
143149
"--no-configured-files",
144150
is_flag=True,
@@ -226,6 +232,7 @@ def bump(
226232
serialize: Optional[List[str]],
227233
search: Optional[str],
228234
replace: Optional[str],
235+
no_regex: bool,
229236
no_configured_files: bool,
230237
ignore_missing_version: bool,
231238
dry_run: bool,
@@ -269,6 +276,7 @@ def bump(
269276
message=message,
270277
commit_args=commit_args,
271278
ignore_missing_version=ignore_missing_version,
279+
no_regex=no_regex,
272280
)
273281

274282
found_config_file = find_config_file(config_file)
@@ -407,6 +415,12 @@ def show(args: List[str], config_file: Optional[str], format_: str, increment: O
407415
envvar="BUMPVERSION_REPLACE",
408416
help="Template for complete string to replace",
409417
)
418+
@click.option(
419+
"--no-regex",
420+
is_flag=True,
421+
envvar="BUMPVERSION_NO_REGEX",
422+
help="Do not treat the search parameter as a regular expression",
423+
)
410424
@click.option(
411425
"--no-configured-files",
412426
is_flag=True,
@@ -440,6 +454,7 @@ def replace(
440454
serialize: Optional[List[str]],
441455
search: Optional[str],
442456
replace: Optional[str],
457+
no_regex: bool,
443458
no_configured_files: bool,
444459
ignore_missing_version: bool,
445460
dry_run: bool,
@@ -469,6 +484,7 @@ def replace(
469484
message=None,
470485
commit_args=None,
471486
ignore_missing_version=ignore_missing_version,
487+
no_regex=no_regex,
472488
)
473489

474490
found_config_file = find_config_file(config_file)

bumpversion/config.py

+4
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class FileConfig(BaseModel):
4141
serialize: Optional[List[str]] # If different from outer scope
4242
search: Optional[str] # If different from outer scope
4343
replace: Optional[str] # If different from outer scope
44+
no_regex: Optional[bool] # If different from outer scope
4445
ignore_missing_version: Optional[bool]
4546

4647

@@ -52,6 +53,7 @@ class Config(BaseSettings):
5253
serialize: List[str] = Field(min_items=1)
5354
search: str
5455
replace: str
56+
no_regex: bool
5557
ignore_missing_version: bool
5658
tag: bool
5759
sign_tags: bool
@@ -87,6 +89,7 @@ def version_config(self) -> "VersionConfig":
8789
"serialize": ["{major}.{minor}.{patch}"],
8890
"search": "{current_version}",
8991
"replace": "{new_version}",
92+
"no_regex": False,
9093
"ignore_missing_version": False,
9194
"tag": False,
9295
"sign_tags": False,
@@ -117,6 +120,7 @@ def get_all_file_configs(config_dict: dict) -> List[FileConfig]:
117120
"search": config_dict["search"],
118121
"replace": config_dict["replace"],
119122
"ignore_missing_version": config_dict["ignore_missing_version"],
123+
"no_regex": config_dict["no_regex"],
120124
}
121125
files = [{k: v for k, v in filecfg.items() if v} for filecfg in config_dict["files"]]
122126
for f in files:

bumpversion/files.py

+41-46
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import glob
33
import logging
44
import re
5+
from copy import deepcopy
56
from difflib import context_diff
67
from typing import List, MutableMapping, Optional
78

@@ -27,6 +28,7 @@ def __init__(
2728
self.serialize = file_cfg.serialize or version_config.serialize_formats
2829
self.search = search or file_cfg.search or version_config.search
2930
self.replace = replace or file_cfg.replace or version_config.replace
31+
self.no_regex = file_cfg.no_regex or False
3032
self.ignore_missing_version = file_cfg.ignore_missing_version or False
3133
self.version_config = VersionConfig(
3234
self.parse, self.serialize, self.search, self.replace, version_config.part_configs
@@ -62,7 +64,7 @@ def contains_version(self, version: Version, context: MutableMapping) -> bool:
6264
Returns:
6365
True if the version number is in fact present.
6466
"""
65-
search_expression = self.search.format(**context)
67+
search_expression = self.get_search_pattern(context)
6668

6769
if self.contains(search_expression):
6870
return True
@@ -75,44 +77,33 @@ def contains_version(self, version: Version, context: MutableMapping) -> bool:
7577
# very specific parts of the file
7678
search_pattern_is_default = self.search == self.version_config.search
7779

78-
if search_pattern_is_default and self.contains(version.original):
79-
# original version is present, and we're not looking for something
80+
if search_pattern_is_default and self.contains(re.compile(re.escape(version.original))):
81+
# The original version is present, and we're not looking for something
8082
# more specific -> this is accepted as a match
8183
return True
8284

8385
# version not found
8486
if self.ignore_missing_version:
8587
return False
86-
raise VersionNotFoundError(f"Did not find '{search_expression}' in file: '{self.path}'")
88+
raise VersionNotFoundError(f"Did not find '{search_expression.pattern}' in file: '{self.path}'")
8789

88-
def contains(self, search: str) -> bool:
90+
def contains(self, search: re.Pattern) -> bool:
8991
"""Does the work of the contains_version method."""
9092
if not search:
9193
return False
9294

93-
f = self.get_file_contents()
94-
search_lines = search.splitlines()
95-
lookbehind = []
96-
97-
for lineno, line in enumerate(f.splitlines(keepends=True)):
98-
lookbehind.append(line.rstrip("\n"))
99-
100-
if len(lookbehind) > len(search_lines):
101-
lookbehind = lookbehind[1:]
102-
103-
if (
104-
search_lines[0] in lookbehind[0]
105-
and search_lines[-1] in lookbehind[-1]
106-
and search_lines[1:-1] == lookbehind[1:-1]
107-
):
108-
logger.info(
109-
"Found '%s' in %s at line %s: %s",
110-
search,
111-
self.path,
112-
lineno - (len(lookbehind) - 1),
113-
line.rstrip(),
114-
)
115-
return True
95+
contents = self.get_file_contents()
96+
97+
for m in re.finditer(search, contents):
98+
line_no = contents.count("\n", 0, m.start(0)) + 1
99+
logger.info(
100+
"Found '%s' in %s at line %s: %s",
101+
search,
102+
self.path,
103+
line_no,
104+
m.string[m.start() : m.end(0)],
105+
)
106+
return True
116107
return False
117108

118109
def replace_version(
@@ -124,24 +115,17 @@ def replace_version(
124115
context["current_version"] = self.version_config.serialize(current_version, context)
125116
if new_version:
126117
context["new_version"] = self.version_config.serialize(new_version, context)
127-
re_context = {key: re.escape(str(value)) for key, value in context.items()}
128118

129-
search_for = self.version_config.search.format(**re_context)
130-
search_for_re = self.compile_regex(search_for)
119+
search_for = self.get_search_pattern(context)
131120
replace_with = self.version_config.replace.format(**context)
132121

133-
if search_for_re:
134-
file_content_after = search_for_re.sub(replace_with, file_content_before)
135-
else:
136-
file_content_after = file_content_before.replace(search_for, replace_with)
122+
file_content_after = search_for.sub(replace_with, file_content_before)
137123

138124
if file_content_before == file_content_after and current_version.original:
139-
search_for_original_formatted = self.version_config.search.format(current_version=current_version.original)
140-
search_for_original_formatted_re = self.compile_regex(re.escape(search_for_original_formatted))
141-
if search_for_original_formatted_re:
142-
file_content_after = search_for_original_formatted_re.sub(replace_with, file_content_before)
143-
else:
144-
file_content_after = file_content_before.replace(search_for_original_formatted, replace_with)
125+
og_context = deepcopy(context)
126+
og_context["current_version"] = current_version.original
127+
search_for_og = self.get_search_pattern(og_context)
128+
file_content_after = search_for_og.sub(replace_with, file_content_before)
145129

146130
if file_content_before != file_content_after:
147131
logger.info("%s file %s:", "Would change" if dry_run else "Changing", self.path)
@@ -164,14 +148,25 @@ def replace_version(
164148
if not dry_run: # pragma: no-coverage
165149
self.write_file_contents(file_content_after)
166150

167-
def compile_regex(self, pattern: str) -> Optional[re.Pattern]:
168-
"""Compile the regex if it is valid, otherwise return None."""
151+
def get_search_pattern(self, context: MutableMapping) -> re.Pattern:
152+
"""Compile and return the regex if it is valid, otherwise return the string."""
153+
# the default search pattern is escaped, so we can still use it in a regex
154+
default = re.compile(re.escape(self.version_config.search.format(**context)), re.MULTILINE | re.DOTALL)
155+
if self.no_regex:
156+
logger.debug("No RegEx flag detected. Searching for the default pattern: '%s'", default.pattern)
157+
return default
158+
159+
re_context = {key: re.escape(str(value)) for key, value in context.items()}
160+
regex_pattern = self.version_config.search.format(**re_context)
169161
try:
170-
search_for_re = re.compile(pattern)
162+
search_for_re = re.compile(regex_pattern, re.MULTILINE | re.DOTALL)
163+
logger.debug("Searching for the regex: '%s'", search_for_re.pattern)
171164
return search_for_re
172165
except re.error as e:
173-
logger.error("Invalid regex '%s' for file %s: %s. Treating it as a regular string.", pattern, self.path, e)
174-
return None
166+
logger.error("Invalid regex '%s' for file %s: %s.", default, self.path, e)
167+
168+
logger.debug("Searching for the default pattern: '%s'", default.pattern)
169+
return default
175170

176171
def __str__(self) -> str: # pragma: no-coverage
177172
return self.path

tests/fixtures/basic_cfg_expected.txt

+4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
'files': [{'filename': 'setup.py',
66
'glob': None,
77
'ignore_missing_version': False,
8+
'no_regex': False,
89
'parse': '(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?',
910
'replace': '{new_version}',
1011
'search': '{current_version}',
@@ -13,6 +14,7 @@
1314
{'filename': 'bumpversion/__init__.py',
1415
'glob': None,
1516
'ignore_missing_version': False,
17+
'no_regex': False,
1618
'parse': '(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?',
1719
'replace': '{new_version}',
1820
'search': '{current_version}',
@@ -21,13 +23,15 @@
2123
{'filename': 'CHANGELOG.md',
2224
'glob': None,
2325
'ignore_missing_version': False,
26+
'no_regex': False,
2427
'parse': '(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?',
2528
'replace': '**unreleased**\n**v{new_version}**',
2629
'search': '**unreleased**',
2730
'serialize': ['{major}.{minor}.{patch}-{release}',
2831
'{major}.{minor}.{patch}']}],
2932
'ignore_missing_version': False,
3033
'message': 'Bump version: {current_version} → {new_version}',
34+
'no_regex': False,
3135
'parse': '(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?',
3236
'parts': {'major': {'first_value': None,
3337
'independent': False,

tests/fixtures/basic_cfg_expected.yaml

+4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ files:
66
- filename: "setup.py"
77
glob: null
88
ignore_missing_version: false
9+
no_regex: false
910
parse: "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?"
1011
replace: "{new_version}"
1112
search: "{current_version}"
@@ -15,6 +16,7 @@ files:
1516
- filename: "bumpversion/__init__.py"
1617
glob: null
1718
ignore_missing_version: false
19+
no_regex: false
1820
parse: "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?"
1921
replace: "{new_version}"
2022
search: "{current_version}"
@@ -24,6 +26,7 @@ files:
2426
- filename: "CHANGELOG.md"
2527
glob: null
2628
ignore_missing_version: false
29+
no_regex: false
2730
parse: "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?"
2831
replace: "**unreleased**\n**v{new_version}**"
2932
search: "**unreleased**"
@@ -32,6 +35,7 @@ files:
3235
- "{major}.{minor}.{patch}"
3336
ignore_missing_version: false
3437
message: "Bump version: {current_version} → {new_version}"
38+
no_regex: false
3539
parse: "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?"
3640
parts:
3741
major:

tests/fixtures/basic_cfg_expected_full.json

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"filename": "setup.py",
99
"glob": null,
1010
"ignore_missing_version": false,
11+
"no_regex": false,
1112
"parse": "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?",
1213
"replace": "{new_version}",
1314
"search": "{current_version}",
@@ -20,6 +21,7 @@
2021
"filename": "bumpversion/__init__.py",
2122
"glob": null,
2223
"ignore_missing_version": false,
24+
"no_regex": false,
2325
"parse": "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?",
2426
"replace": "{new_version}",
2527
"search": "{current_version}",
@@ -32,6 +34,7 @@
3234
"filename": "CHANGELOG.md",
3335
"glob": null,
3436
"ignore_missing_version": false,
37+
"no_regex": false,
3538
"parse": "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?",
3639
"replace": "**unreleased**\n**v{new_version}**",
3740
"search": "**unreleased**",
@@ -43,6 +46,7 @@
4346
],
4447
"ignore_missing_version": false,
4548
"message": "Bump version: {current_version} \u2192 {new_version}",
49+
"no_regex": false,
4650
"parse": "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?",
4751
"parts": {
4852
"major": {

tests/test_cli.py

+8-6
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ def test_listing_with_version_part(tmp_path: Path, fixtures_path: Path):
205205
"serialize=['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}']",
206206
"search={current_version}",
207207
"replace={new_version}",
208+
"no_regex=False",
208209
"ignore_missing_version=False",
209210
"tag=True",
210211
"sign_tags=False",
@@ -219,15 +220,15 @@ def test_listing_with_version_part(tmp_path: Path, fixtures_path: Path):
219220
"{'filename': 'setup.py', 'glob': None, 'parse': "
220221
"'(?P<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[a-z]+))?', 'serialize': "
221222
"['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'], 'search': '{current_version}', "
222-
"'replace': '{new_version}', 'ignore_missing_version': False}, "
223+
"'replace': '{new_version}', 'no_regex': False, 'ignore_missing_version': False}, "
223224
"{'filename': 'bumpversion/__init__.py', 'glob': None, 'parse': "
224225
"'(?P<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[a-z]+))?', 'serialize': "
225226
"['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'], 'search': '{current_version}', "
226-
"'replace': '{new_version}', 'ignore_missing_version': False}, "
227+
"'replace': '{new_version}', 'no_regex': False, 'ignore_missing_version': False}, "
227228
"{'filename': 'CHANGELOG.md', 'glob': None, 'parse': "
228229
"'(?P<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[a-z]+))?', 'serialize': "
229230
"['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'], 'search': '**unreleased**', "
230-
"'replace': '**unreleased**\\n**v{new_version}**', 'ignore_missing_version': False}]"
231+
"'replace': '**unreleased**\\n**v{new_version}**', 'no_regex': False, 'ignore_missing_version': False}]"
231232
),
232233
}
233234

@@ -257,6 +258,7 @@ def test_listing_without_version_part(tmp_path: Path, fixtures_path: Path):
257258
"serialize=['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}']",
258259
"search={current_version}",
259260
"replace={new_version}",
261+
"no_regex=False",
260262
"ignore_missing_version=False",
261263
"tag=True",
262264
"sign_tags=False",
@@ -271,15 +273,15 @@ def test_listing_without_version_part(tmp_path: Path, fixtures_path: Path):
271273
"{'filename': 'setup.py', 'glob': None, 'parse': "
272274
"'(?P<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[a-z]+))?', 'serialize': "
273275
"['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'], 'search': '{current_version}', "
274-
"'replace': '{new_version}', 'ignore_missing_version': False}, "
276+
"'replace': '{new_version}', 'no_regex': False, 'ignore_missing_version': False}, "
275277
"{'filename': 'bumpversion/__init__.py', 'glob': None, 'parse': "
276278
"'(?P<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[a-z]+))?', 'serialize': "
277279
"['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'], 'search': '{current_version}', "
278-
"'replace': '{new_version}', 'ignore_missing_version': False}, "
280+
"'replace': '{new_version}', 'no_regex': False, 'ignore_missing_version': False}, "
279281
"{'filename': 'CHANGELOG.md', 'glob': None, 'parse': "
280282
"'(?P<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[a-z]+))?', 'serialize': "
281283
"['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'], 'search': '**unreleased**', "
282-
"'replace': '**unreleased**\\n**v{new_version}**', 'ignore_missing_version': False}]"
284+
"'replace': '**unreleased**\\n**v{new_version}**', 'no_regex': False, 'ignore_missing_version': False}]"
283285
),
284286
}
285287

0 commit comments

Comments
 (0)