diff --git a/README.md b/README.md
index bc5fe135..0c1e5016 100644
--- a/README.md
+++ b/README.md
@@ -401,6 +401,55 @@ serialize =
might be present multiple times in the file and you mean to only bump one of the
occurrences. Can be multiple lines, templated using [Python Format String Syntax](https://docs.python.org/3/library/string.html#format-string-syntax)
+#### `search_regex =`
+ **default:** none
+
+ Regex to search for the string to be replaced in the file.
+ Support `{current_version}` template string (e.g. `(?<=version='){current_version}(?=')`).
+ If search_regex is defined, the search template string will be ignored in the section.
+
+```ini
+[bumpversion]
+current_version = 2.4.5
+
+[bumpversion:file:pom.xml]
+search_regex = (?<=test-app-java\n\s{4}){current_version}(?=)
+search = {current_version} ; will be ignored
+replace = {new_version}
+```
+ Example of use, when the application version is the same as some dependencies (java application, pom.xml):
+```xml
+ ...
+ test-app-java
+ 2.4.5
+ jar
+ ...
+
+
+
+ org.springframework.boot
+ spring-boot-dependencies
+ 2.4.5
+ pom
+ import
+
+
+
+ ...
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+ 2.4.5
+
+
+
+ ...
+```
+ Also available as `--search-regex`
+
#### `replace =`
**default:** `{new_version}`
diff --git a/bumpversion/cli.py b/bumpversion/cli.py
index b107e10d..d9a376f0 100644
--- a/bumpversion/cli.py
+++ b/bumpversion/cli.py
@@ -55,7 +55,7 @@
)
logger_list = logging.getLogger("bumpversion.list")
-logger = logging.getLogger(__name__)
+logger = logging.getLogger("bumpversion.cli")
time_context = {"now": datetime.now(), "utcnow": datetime.utcnow()}
special_char_context = {c: c for c in ("#", ";")}
@@ -68,6 +68,7 @@
"--parse",
"--serialize",
"--search",
+ "--search-regex",
"--replace",
"--tag-name",
"--tag-message",
@@ -361,6 +362,10 @@ def _load_configuration(config_file, explicit_config, defaults):
"search", "{current_version}"
)
+ if "search_regex" not in section_config:
+ section_config["search_regex"] = defaults.get("search_regex", "")
+
+
if "replace" not in section_config:
section_config["replace"] = defaults.get("replace", "{new_version}")
@@ -405,6 +410,11 @@ def _parse_arguments_phase_2(args, known_args, defaults, root_parser):
help="Template for complete string to search",
default=defaults.get("search", "{current_version}"),
)
+ parser2.add_argument(
+ "--search-regex",
+ metavar="SEARCH_REGEX",
+ help="Regex pattern to search",
+ )
parser2.add_argument(
"--replace",
metavar="REPLACE",
@@ -426,6 +436,7 @@ def _setup_versionconfig(known_args, part_configs):
parse=known_args.parse,
serialize=known_args.serialize,
search=known_args.search,
+ search_regex=known_args.search_regex,
replace=known_args.replace,
part_configs=part_configs,
)
diff --git a/bumpversion/exceptions.py b/bumpversion/exceptions.py
index 73f51479..5bed1e5b 100644
--- a/bumpversion/exceptions.py
+++ b/bumpversion/exceptions.py
@@ -30,3 +30,6 @@ class VersionNotFoundException(BumpVersionException):
class InvalidVersionPartException(BumpVersionException):
"""The specified part (e.g. 'bugfix') was not found"""
+
+class SearchRegexException(BumpVersionException):
+ """Search regex error"""
diff --git a/bumpversion/utils.py b/bumpversion/utils.py
index f872f230..7933102d 100644
--- a/bumpversion/utils.py
+++ b/bumpversion/utils.py
@@ -4,9 +4,10 @@
import logging
import os
-from bumpversion.exceptions import VersionNotFoundException
+from bumpversion.exceptions import VersionNotFoundException, SearchRegexException
+import re
-logger = logging.getLogger(__name__)
+logger = logging.getLogger("bumpversion.cli")
class DiscardDefaultIfSpecifiedAppendAction(_AppendAction):
@@ -46,59 +47,104 @@ def should_contain_version(self, version, context):
Return normally if the version number is in fact present.
"""
context["current_version"] = self._versionconfig.serialize(version, context)
- search_expression = self._versionconfig.search.format(**context)
- if self.contains(search_expression):
- return
+ if not self._versionconfig.search_regex:
+ search_expression = self._versionconfig.search.format(**context)
- # the `search` pattern did not match, but the original supplied
- # version number (representing the same version part values) might
- # match instead.
+ if self.contains(search_expression):
+ return
- # check whether `search` isn't customized, i.e. should match only
- # very specific parts of the file
- search_pattern_is_default = self._versionconfig.search == "{current_version}"
+ # the `search` pattern did not match, but the original supplied
+ # version number (representing the same version part values) might
+ # match instead.
- if search_pattern_is_default and self.contains(version.original):
- # original version is present and we're not looking for something
- # more specific -> this is accepted as a match
- return
+ # check whether `search` isn't customized, i.e. should match only
+ # very specific parts of the file
+ search_pattern_is_default = self._versionconfig.search == "{current_version}"
- # version not found
- raise VersionNotFoundException(
- "Did not find '{}' in file: '{}'".format(
- search_expression, self.path
- )
- )
+ if search_pattern_is_default and self.contains(version.original):
+ # original version is present and we're not looking for something
+ # more specific -> this is accepted as a match
+ return
- def contains(self, search):
- if not search:
- return False
+ # version not found
+ raise VersionNotFoundException(
+ "Did not find '{}' in file: '{}'".format(
+ search_expression, self.path
+ )
+ )
+ else:
+ regex = self._escape_regex_braces(self._versionconfig.search_regex)
+ context["current_version"] = re.escape(context["current_version"])
+ search_expression = regex.format(**context)
+
+ try:
+ if self.contains(search_expression, regex=True):
+ return
+ except re.error as error:
+ raise SearchRegexException("Search regex error: {}".format(error))
+
+ # version not found
+ raise VersionNotFoundException(
+ "Did not match '{}' in file: '{}'".format(
+ search_expression, self.path
+ )
+ )
- with open(self.path, "rt", encoding="utf-8") as f:
- search_lines = search.splitlines()
- lookbehind = []
+ def _escape_regex_braces(self, regex):
+ # Escape regex braces to support current_version template string
+ regex = re.sub(r"\{(?!current_version)", "{{", regex)
+ regex = re.sub(r"(? len(search_lines):
- lookbehind = lookbehind[1:]
+ if not search:
+ return False
- if (
- search_lines[0] in lookbehind[0]
- and search_lines[-1] in lookbehind[-1]
- and search_lines[1:-1] == lookbehind[1:-1]
- ):
+ if not regex:
+ with open(self.path, "rt", encoding="utf-8") as f:
+ search_lines = search.splitlines()
+ lookbehind = []
+
+ for lineno, line in enumerate(f.readlines()):
+ lookbehind.append(line.rstrip("\n"))
+
+ if len(lookbehind) > len(search_lines):
+ lookbehind = lookbehind[1:]
+
+ if (
+ search_lines[0] in lookbehind[0]
+ and search_lines[-1] in lookbehind[-1]
+ and search_lines[1:-1] == lookbehind[1:-1]
+ ):
+ logger.info(
+ "Found '%s' in %s at line %s:\n%s",
+ search,
+ self.path,
+ lineno - (len(lookbehind) - 2),
+ line.rstrip(),
+ )
+ found_or_match = True
+ else:
+ with open(self.path, "rt", encoding="utf-8") as f:
+ file_content = f.read()
+ look_around = 100
+ matches = re.compile(search, re.MULTILINE|re.DOTALL)
+ for match in matches.finditer(file_content):
+ lineno, line = file_content[0:match.start()].count("\n"), file_content[(match.start()-look_around):(match.end()+look_around)]
+ line = "\n".join(line.split("\n")[1:-1]) if len(line.split("\n")) >= 3 else line
logger.info(
- "Found '%s' in %s at line %s: %s",
+ "Matched '%s' in %s at line %s:\n%s",
search,
self.path,
- lineno - (len(lookbehind) - 1),
+ (lineno + 1),
line.rstrip(),
)
- return True
- return False
+ found_or_match = True
+
+ return found_or_match
def replace(self, current_version, new_version, context, dry_run):
@@ -112,11 +158,21 @@ def replace(self, current_version, new_version, context, dry_run):
context["new_version"] = self._versionconfig.serialize(new_version, context)
search_for = self._versionconfig.search.format(**context)
+ search_for_regex = self._versionconfig.search_regex
+ if search_for_regex:
+ regex = self._escape_regex_braces(self._versionconfig.search_regex)
+ context["current_version"] = re.escape(context["current_version"])
+ search_for_regex = regex.format(**context)
replace_with = self._versionconfig.replace.format(**context)
file_content_after = file_content_before.replace(search_for, replace_with)
- if file_content_before == file_content_after:
+ try:
+ file_content_after = re.sub(search_for_regex, replace_with, file_content_before) if search_for_regex else file_content_before.replace(search_for, replace_with)
+ except re.error as error:
+ raise SearchRegexException("Search regex error: {}".format(error))
+
+ if not search_for_regex and file_content_before == file_content_after:
# TODO expose this to be configurable
file_content_after = file_content_before.replace(
current_version.original, replace_with
diff --git a/bumpversion/version_part.py b/bumpversion/version_part.py
index cb8c2fd8..40399dc4 100644
--- a/bumpversion/version_part.py
+++ b/bumpversion/version_part.py
@@ -141,7 +141,7 @@ class VersionConfig:
Hold a complete representation of a version string.
"""
- def __init__(self, parse, serialize, search, replace, part_configs=None):
+ def __init__(self, parse, serialize, search, search_regex, replace, part_configs=None):
try:
self.parse_regex = re.compile(parse, re.VERBOSE)
except sre_constants.error as e:
@@ -156,6 +156,7 @@ def __init__(self, parse, serialize, search, replace, part_configs=None):
self.part_configs = part_configs
self.search = search
+ self.search_regex = search_regex
self.replace = replace
def order(self):
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 1589da01..db9c1f0e 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -105,6 +105,7 @@ def _mock_calls_to_string(called_mock):
[--parse REGEX]
[--serialize FORMAT]
[--search SEARCH]
+[--search-regex SEARCH_REGEX]
[--replace REPLACE]
[--current-version VERSION]
[--no-configured-files]
@@ -142,6 +143,8 @@ def _mock_calls_to_string(called_mock):
(default: ['{major}.{minor}.{patch}'])
--search SEARCH Template for complete string to search (default:
{current_version})
+ --search-regex SEARCH_REGEX
+ Regex pattern to search (default: None)
--replace REPLACE Template for complete string to replace (default:
{new_version})
--current-version VERSION
@@ -170,6 +173,24 @@ def _mock_calls_to_string(called_mock):
{current_version} → {new_version})
""" % DESCRIPTION).lstrip()
+def test_search_regex(tmpdir):
+ tmpdir.join("VERSION").write('"version"="0.9.34"')
+ tmpdir.chdir()
+ main(shlex_split("""patch --current-version 0.9.34 --search-regex '(?<="version"=").+(?=")' VERSION"""))
+ assert '"version"="0.9.35"' == tmpdir.join("VERSION").read()
+
+def test_search_regex_with_config(tmpdir):
+ tmpdir.join("VERSION").write('"version"="0.9.34"')
+ tmpdir.join(".bumpversion.cfg").write("""[bumpversion]
+current_version: 0.9.34
+[bumpversion:file:VERSION]
+search_regex = (?<="version"=").+(?=")
+replace = {new_version}""")
+
+ tmpdir.chdir()
+ main(shlex_split("patch"))
+
+ assert '"version"="0.9.35"' == tmpdir.join("VERSION").read()
def test_usage_string(tmpdir, capsys):
tmpdir.chdir()
@@ -493,9 +514,9 @@ def test_dry_run_verbose_log(tmpdir, vcs):
'Parsed the following values: major={}, minor={}, patch={}'.format(p_parts[0], p_parts[1], p_parts[2])),
('bumpversion.cli', 'INFO', "New version will be '{}'".format(patch)),
('bumpversion.cli', 'INFO', 'Asserting files {} contain the version string...'.format(file)),
- ('bumpversion.utils', 'INFO', "Found '{v}' in {f} at line 0: {v}".format(v=version, f=file)), # verbose
- ('bumpversion.utils', 'INFO', 'Would change file {}:'.format(file)), # dry-run change to 'would'
- ('bumpversion.utils', 'INFO',
+ ('bumpversion.cli', 'INFO', "Found '{v}' in {f} at line 1:\n{v}".format(v=version, f=file)), # verbose
+ ('bumpversion.cli', 'INFO', 'Would change file {}:'.format(file)), # dry-run change to 'would'
+ ('bumpversion.cli', 'INFO',
'--- a/{f}\n+++ b/{f}\n@@ -1 +1 @@\n-{v}\n+{p}'.format(f=file, v=version, p=patch)),
('bumpversion.list', 'INFO', 'current_version={}'.format(version)),
('bumpversion.list', 'INFO', 'tag=True'),
@@ -1311,9 +1332,9 @@ def test_log_no_config_file_info_message(tmpdir):
('bumpversion.version_part', 'INFO', 'Parsed the following values: major=1, minor=0, patch=1'),
('bumpversion.cli', 'INFO', "New version will be '1.0.1'"),
('bumpversion.cli', 'INFO', 'Asserting files a_file.txt contain the version string...'),
- ('bumpversion.utils', 'INFO', "Found '1.0.0' in a_file.txt at line 0: 1.0.0"),
- ('bumpversion.utils', 'INFO', 'Changing file a_file.txt:'),
- ('bumpversion.utils', 'INFO', '--- a/a_file.txt\n+++ b/a_file.txt\n@@ -1 +1 @@\n-1.0.0\n+1.0.1'),
+ ('bumpversion.cli', 'INFO', "Found '1.0.0' in a_file.txt at line 1:\n1.0.0"),
+ ('bumpversion.cli', 'INFO', 'Changing file a_file.txt:'),
+ ('bumpversion.cli', 'INFO', '--- a/a_file.txt\n+++ b/a_file.txt\n@@ -1 +1 @@\n-1.0.0\n+1.0.1'),
('bumpversion.cli', 'INFO', 'Would write to config file .bumpversion.cfg:'),
('bumpversion.cli', 'INFO', '[bumpversion]\ncurrent_version = 1.0.1\n\n'),
order_matters=True
@@ -1379,9 +1400,9 @@ def test_complex_info_logging(tmpdir):
('bumpversion.version_part', 'INFO', 'Parsed the following values: major=0, minor=4, patch=1'),
('bumpversion.cli', 'INFO', "New version will be '0.4.1'"),
('bumpversion.cli', 'INFO', 'Asserting files fileE contain the version string...'),
- ('bumpversion.utils', 'INFO', "Found '0.4' in fileE at line 0: 0.4"),
- ('bumpversion.utils', 'INFO', 'Changing file fileE:'),
- ('bumpversion.utils', 'INFO', '--- a/fileE\n+++ b/fileE\n@@ -1 +1 @@\n-0.4\n+0.4.1'),
+ ('bumpversion.cli', 'INFO', "Found '0.4' in fileE at line 1:\n0.4"),
+ ('bumpversion.cli', 'INFO', 'Changing file fileE:'),
+ ('bumpversion.cli', 'INFO', '--- a/fileE\n+++ b/fileE\n@@ -1 +1 @@\n-0.4\n+0.4.1'),
('bumpversion.list', 'INFO', 'current_version=0.4'),
('bumpversion.list', 'INFO', 'serialize=\n{major}.{minor}.{patch}\n{major}.{minor}'),
('bumpversion.list', 'INFO', 'parse=(?P\\d+)\\.(?P\\d+)(\\.(?P\\d+))?'),
@@ -1428,9 +1449,9 @@ def test_subjunctive_dry_run_logging(tmpdir, vcs):
('bumpversion.version_part', 'INFO', 'Parsed the following values: major=0, minor=8, patch=1'),
('bumpversion.cli', 'INFO', "New version will be '0.8.1'"),
('bumpversion.cli', 'INFO', 'Asserting files dont_touch_me.txt contain the version string...'),
- ('bumpversion.utils', 'INFO', "Found '0.8' in dont_touch_me.txt at line 0: 0.8"),
- ('bumpversion.utils', 'INFO', 'Would change file dont_touch_me.txt:'),
- ('bumpversion.utils', 'INFO', '--- a/dont_touch_me.txt\n+++ b/dont_touch_me.txt\n@@ -1 +1 @@\n-0.8\n+0.8.1'),
+ ('bumpversion.cli', 'INFO', "Found '0.8' in dont_touch_me.txt at line 1:\n0.8"),
+ ('bumpversion.cli', 'INFO', 'Would change file dont_touch_me.txt:'),
+ ('bumpversion.cli', 'INFO', '--- a/dont_touch_me.txt\n+++ b/dont_touch_me.txt\n@@ -1 +1 @@\n-0.8\n+0.8.1'),
('bumpversion.list', 'INFO', 'current_version=0.8'),
('bumpversion.list', 'INFO', 'commit=True'),
('bumpversion.list', 'INFO', 'tag=True'),
@@ -1479,9 +1500,9 @@ def test_log_commit_message_if_no_commit_tag_but_usable_vcs(tmpdir, vcs):
('bumpversion.version_part', 'INFO', 'Parsed the following values: major=0, minor=3, patch=4'),
('bumpversion.cli', 'INFO', "New version will be '0.3.4'"),
('bumpversion.cli', 'INFO', 'Asserting files please_touch_me.txt contain the version string...'),
- ('bumpversion.utils', 'INFO', "Found '0.3.3' in please_touch_me.txt at line 0: 0.3.3"),
- ('bumpversion.utils', 'INFO', 'Changing file please_touch_me.txt:'),
- ('bumpversion.utils', 'INFO', '--- a/please_touch_me.txt\n+++ b/please_touch_me.txt\n@@ -1 +1 @@\n-0.3.3\n+0.3.4'),
+ ('bumpversion.cli', 'INFO', "Found '0.3.3' in please_touch_me.txt at line 1:\n0.3.3"),
+ ('bumpversion.cli', 'INFO', 'Changing file please_touch_me.txt:'),
+ ('bumpversion.cli', 'INFO', '--- a/please_touch_me.txt\n+++ b/please_touch_me.txt\n@@ -1 +1 @@\n-0.3.3\n+0.3.4'),
('bumpversion.list', 'INFO', 'current_version=0.3.3'),
('bumpversion.list', 'INFO', 'commit=False'),
('bumpversion.list', 'INFO', 'tag=False'),
@@ -1814,12 +1835,12 @@ def test_search_replace_to_avoid_updating_unconcerned_lines(tmpdir):
('bumpversion.version_part', 'INFO', 'Parsed the following values: major=1, minor=6, patch=0'),
('bumpversion.cli', 'INFO', "New version will be '1.6.0'"),
('bumpversion.cli', 'INFO', 'Asserting files requirements.txt, CHANGELOG.md contain the version string...'),
- ('bumpversion.utils', 'INFO', "Found 'MyProject==1.5.6' in requirements.txt at line 1: MyProject==1.5.6"),
- ('bumpversion.utils', 'INFO', "Found '## [Unreleased]' in CHANGELOG.md at line 3: ## [Unreleased]"),
- ('bumpversion.utils', 'INFO', 'Changing file requirements.txt:'),
- ('bumpversion.utils', 'INFO', '--- a/requirements.txt\n+++ b/requirements.txt\n@@ -1,2 +1,2 @@\n Django>=1.5.6,<1.6\n-MyProject==1.5.6\n+MyProject==1.6.0'),
- ('bumpversion.utils', 'INFO', 'Changing file CHANGELOG.md:'),
- ('bumpversion.utils', 'INFO', '--- a/CHANGELOG.md\n+++ b/CHANGELOG.md\n@@ -2,6 +2,8 @@\n # https://keepachangelog.com/en/1.0.0/\n \n ## [Unreleased]\n+\n+## [1.6.0] - %s\n ### Added\n - Foobar\n ' % utc_today),
+ ('bumpversion.cli', 'INFO', "Found 'MyProject==1.5.6' in requirements.txt at line 2:\nMyProject==1.5.6"),
+ ('bumpversion.cli', 'INFO', "Found '## [Unreleased]' in CHANGELOG.md at line 4:\n## [Unreleased]"),
+ ('bumpversion.cli', 'INFO', 'Changing file requirements.txt:'),
+ ('bumpversion.cli', 'INFO', '--- a/requirements.txt\n+++ b/requirements.txt\n@@ -1,2 +1,2 @@\n Django>=1.5.6,<1.6\n-MyProject==1.5.6\n+MyProject==1.6.0'),
+ ('bumpversion.cli', 'INFO', 'Changing file CHANGELOG.md:'),
+ ('bumpversion.cli', 'INFO', '--- a/CHANGELOG.md\n+++ b/CHANGELOG.md\n@@ -2,6 +2,8 @@\n # https://keepachangelog.com/en/1.0.0/\n \n ## [Unreleased]\n+\n+## [1.6.0] - %s\n ### Added\n - Foobar\n ' % utc_today),
('bumpversion.list', 'INFO', 'current_version=1.5.6'),
('bumpversion.list', 'INFO', 'new_version=1.6.0'),
('bumpversion.cli', 'INFO', 'Writing to config file .bumpversion.cfg:'),