From ebd8186cc5dd6f6a76dbdfbb04096e84d067c4a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20de=20Ara=C3=BAjo=20=28Americas=29?= Date: Sat, 22 May 2021 17:38:25 -0300 Subject: [PATCH 1/3] Add search with regex support. --- README.md | 18 +++++ bumpversion/cli.py | 13 +++- bumpversion/exceptions.py | 3 + bumpversion/utils.py | 138 +++++++++++++++++++++++++----------- bumpversion/version_part.py | 3 +- tests/test_cli.py | 63 ++++++++++------ 6 files changed, 174 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index bc5fe135..fe489740 100644 --- a/README.md +++ b/README.md @@ -401,6 +401,24 @@ 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 = 1.5.6 + +[bumpversion:file:requirements.txt] +search_regex = (?<=MyProject==){current_version} +search = MyProject=={current_version} ; will be ignored +replace = MyProject=={new_version} +``` + 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:'), From 14350ff6e9d3dbe66337299ee699b808728955ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20de=20Ar=C3=A1ujo=20=28Americas=29?= Date: Tue, 20 Jul 2021 14:47:03 -0300 Subject: [PATCH 2/3] Update README.md --- README.md | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fe489740..f6145158 100644 --- a/README.md +++ b/README.md @@ -412,10 +412,41 @@ serialize = [bumpversion] current_version = 1.5.6 -[bumpversion:file:requirements.txt] -search_regex = (?<=MyProject==){current_version} -search = MyProject=={current_version} ; will be ignored -replace = MyProject=={new_version} +[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` From 8c26e88a9415b2481ede54b5de9dfbd74bd340b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20de=20Ar=C3=A1ujo=20=28Americas=29?= Date: Tue, 20 Jul 2021 14:47:03 -0300 Subject: [PATCH 3/3] Update README.md --- README.md | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fe489740..0c1e5016 100644 --- a/README.md +++ b/README.md @@ -410,12 +410,43 @@ serialize = ```ini [bumpversion] -current_version = 1.5.6 +current_version = 2.4.5 -[bumpversion:file:requirements.txt] -search_regex = (?<=MyProject==){current_version} -search = MyProject=={current_version} ; will be ignored -replace = MyProject=={new_version} +[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`