Skip to content

Commit

Permalink
Added configuration and command-line no_regex option
Browse files Browse the repository at this point in the history
- Global and individual file configurations available for `no_regex`
- Command-line flag `--no-regex` flag added for `bump` and `replace` sub-commands
  • Loading branch information
coordt committed Jul 16, 2023
1 parent 0210d74 commit a295a32
Show file tree
Hide file tree
Showing 8 changed files with 87 additions and 54 deletions.
16 changes: 16 additions & 0 deletions bumpversion/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ def cli(ctx: Context) -> None:
envvar="BUMPVERSION_REPLACE",
help="Template for complete string to replace",
)
@click.option(
"--no-regex",
is_flag=True,
envvar="BUMPVERSION_NO_REGEX",
help="Do not treat the search parameter as a regular expression",
)
@click.option(
"--no-configured-files",
is_flag=True,
Expand Down Expand Up @@ -226,6 +232,7 @@ def bump(
serialize: Optional[List[str]],
search: Optional[str],
replace: Optional[str],
no_regex: bool,
no_configured_files: bool,
ignore_missing_version: bool,
dry_run: bool,
Expand Down Expand Up @@ -269,6 +276,7 @@ def bump(
message=message,
commit_args=commit_args,
ignore_missing_version=ignore_missing_version,
no_regex=no_regex,
)

found_config_file = find_config_file(config_file)
Expand Down Expand Up @@ -407,6 +415,12 @@ def show(args: List[str], config_file: Optional[str], format_: str, increment: O
envvar="BUMPVERSION_REPLACE",
help="Template for complete string to replace",
)
@click.option(
"--no-regex",
is_flag=True,
envvar="BUMPVERSION_NO_REGEX",
help="Do not treat the search parameter as a regular expression",
)
@click.option(
"--no-configured-files",
is_flag=True,
Expand Down Expand Up @@ -440,6 +454,7 @@ def replace(
serialize: Optional[List[str]],
search: Optional[str],
replace: Optional[str],
no_regex: bool,
no_configured_files: bool,
ignore_missing_version: bool,
dry_run: bool,
Expand Down Expand Up @@ -469,6 +484,7 @@ def replace(
message=None,
commit_args=None,
ignore_missing_version=ignore_missing_version,
no_regex=no_regex,
)

found_config_file = find_config_file(config_file)
Expand Down
4 changes: 4 additions & 0 deletions bumpversion/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class FileConfig(BaseModel):
serialize: Optional[List[str]] # If different from outer scope
search: Optional[str] # If different from outer scope
replace: Optional[str] # If different from outer scope
no_regex: Optional[bool] # If different from outer scope
ignore_missing_version: Optional[bool]


Expand All @@ -52,6 +53,7 @@ class Config(BaseSettings):
serialize: List[str] = Field(min_items=1)
search: str
replace: str
no_regex: bool
ignore_missing_version: bool
tag: bool
sign_tags: bool
Expand Down Expand Up @@ -87,6 +89,7 @@ def version_config(self) -> "VersionConfig":
"serialize": ["{major}.{minor}.{patch}"],
"search": "{current_version}",
"replace": "{new_version}",
"no_regex": False,
"ignore_missing_version": False,
"tag": False,
"sign_tags": False,
Expand Down Expand Up @@ -117,6 +120,7 @@ def get_all_file_configs(config_dict: dict) -> List[FileConfig]:
"search": config_dict["search"],
"replace": config_dict["replace"],
"ignore_missing_version": config_dict["ignore_missing_version"],
"no_regex": config_dict["no_regex"],
}
files = [{k: v for k, v in filecfg.items() if v} for filecfg in config_dict["files"]]
for f in files:
Expand Down
87 changes: 41 additions & 46 deletions bumpversion/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import glob
import logging
import re
from copy import deepcopy
from difflib import context_diff
from typing import List, MutableMapping, Optional

Expand All @@ -27,6 +28,7 @@ def __init__(
self.serialize = file_cfg.serialize or version_config.serialize_formats
self.search = search or file_cfg.search or version_config.search
self.replace = replace or file_cfg.replace or version_config.replace
self.no_regex = file_cfg.no_regex or False
self.ignore_missing_version = file_cfg.ignore_missing_version or False
self.version_config = VersionConfig(
self.parse, self.serialize, self.search, self.replace, version_config.part_configs
Expand Down Expand Up @@ -62,7 +64,7 @@ def contains_version(self, version: Version, context: MutableMapping) -> bool:
Returns:
True if the version number is in fact present.
"""
search_expression = self.search.format(**context)
search_expression = self.get_search_pattern(context)

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

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

# version not found
if self.ignore_missing_version:
return False
raise VersionNotFoundError(f"Did not find '{search_expression}' in file: '{self.path}'")
raise VersionNotFoundError(f"Did not find '{search_expression.pattern}' in file: '{self.path}'")

def contains(self, search: str) -> bool:
def contains(self, search: re.Pattern) -> bool:
"""Does the work of the contains_version method."""
if not search:
return False

f = self.get_file_contents()
search_lines = search.splitlines()
lookbehind = []

for lineno, line in enumerate(f.splitlines(keepends=True)):
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: %s",
search,
self.path,
lineno - (len(lookbehind) - 1),
line.rstrip(),
)
return True
contents = self.get_file_contents()

for m in re.finditer(search, contents):
line_no = contents.count("\n", 0, m.start(0)) + 1
logger.info(
"Found '%s' in %s at line %s: %s",
search,
self.path,
line_no,
m.string[m.start() : m.end(0)],
)
return True
return False

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

search_for = self.version_config.search.format(**re_context)
search_for_re = self.compile_regex(search_for)
search_for = self.get_search_pattern(context)
replace_with = self.version_config.replace.format(**context)

if search_for_re:
file_content_after = search_for_re.sub(replace_with, file_content_before)
else:
file_content_after = file_content_before.replace(search_for, replace_with)
file_content_after = search_for.sub(replace_with, file_content_before)

if file_content_before == file_content_after and current_version.original:
search_for_original_formatted = self.version_config.search.format(current_version=current_version.original)
search_for_original_formatted_re = self.compile_regex(re.escape(search_for_original_formatted))
if search_for_original_formatted_re:
file_content_after = search_for_original_formatted_re.sub(replace_with, file_content_before)
else:
file_content_after = file_content_before.replace(search_for_original_formatted, replace_with)
og_context = deepcopy(context)
og_context["current_version"] = current_version.original
search_for_og = self.get_search_pattern(og_context)
file_content_after = search_for_og.sub(replace_with, file_content_before)

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

def compile_regex(self, pattern: str) -> Optional[re.Pattern]:
"""Compile the regex if it is valid, otherwise return None."""
def get_search_pattern(self, context: MutableMapping) -> re.Pattern:
"""Compile and return the regex if it is valid, otherwise return the string."""
# the default search pattern is escaped, so we can still use it in a regex
default = re.compile(re.escape(self.version_config.search.format(**context)), re.MULTILINE | re.DOTALL)
if self.no_regex:
logger.debug("No RegEx flag detected. Searching for the default pattern: '%s'", default.pattern)
return default

re_context = {key: re.escape(str(value)) for key, value in context.items()}
regex_pattern = self.version_config.search.format(**re_context)
try:
search_for_re = re.compile(pattern)
search_for_re = re.compile(regex_pattern, re.MULTILINE | re.DOTALL)
logger.debug("Searching for the regex: '%s'", search_for_re.pattern)
return search_for_re
except re.error as e:
logger.error("Invalid regex '%s' for file %s: %s. Treating it as a regular string.", pattern, self.path, e)
return None
logger.error("Invalid regex '%s' for file %s: %s.", default, self.path, e)

logger.debug("Searching for the default pattern: '%s'", default.pattern)
return default

def __str__(self) -> str: # pragma: no-coverage
return self.path
Expand Down
4 changes: 4 additions & 0 deletions tests/fixtures/basic_cfg_expected.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
'files': [{'filename': 'setup.py',
'glob': None,
'ignore_missing_version': False,
'no_regex': False,
'parse': '(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?',
'replace': '{new_version}',
'search': '{current_version}',
Expand All @@ -13,6 +14,7 @@
{'filename': 'bumpversion/__init__.py',
'glob': None,
'ignore_missing_version': False,
'no_regex': False,
'parse': '(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?',
'replace': '{new_version}',
'search': '{current_version}',
Expand All @@ -21,13 +23,15 @@
{'filename': 'CHANGELOG.md',
'glob': None,
'ignore_missing_version': False,
'no_regex': False,
'parse': '(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?',
'replace': '**unreleased**\n**v{new_version}**',
'search': '**unreleased**',
'serialize': ['{major}.{minor}.{patch}-{release}',
'{major}.{minor}.{patch}']}],
'ignore_missing_version': False,
'message': 'Bump version: {current_version} → {new_version}',
'no_regex': False,
'parse': '(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?',
'parts': {'major': {'first_value': None,
'independent': False,
Expand Down
4 changes: 4 additions & 0 deletions tests/fixtures/basic_cfg_expected.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ files:
- filename: "setup.py"
glob: null
ignore_missing_version: false
no_regex: false
parse: "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?"
replace: "{new_version}"
search: "{current_version}"
Expand All @@ -15,6 +16,7 @@ files:
- filename: "bumpversion/__init__.py"
glob: null
ignore_missing_version: false
no_regex: false
parse: "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?"
replace: "{new_version}"
search: "{current_version}"
Expand All @@ -24,6 +26,7 @@ files:
- filename: "CHANGELOG.md"
glob: null
ignore_missing_version: false
no_regex: false
parse: "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?"
replace: "**unreleased**\n**v{new_version}**"
search: "**unreleased**"
Expand All @@ -32,6 +35,7 @@ files:
- "{major}.{minor}.{patch}"
ignore_missing_version: false
message: "Bump version: {current_version} → {new_version}"
no_regex: false
parse: "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?"
parts:
major:
Expand Down
4 changes: 4 additions & 0 deletions tests/fixtures/basic_cfg_expected_full.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"filename": "setup.py",
"glob": null,
"ignore_missing_version": false,
"no_regex": false,
"parse": "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?",
"replace": "{new_version}",
"search": "{current_version}",
Expand All @@ -20,6 +21,7 @@
"filename": "bumpversion/__init__.py",
"glob": null,
"ignore_missing_version": false,
"no_regex": false,
"parse": "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?",
"replace": "{new_version}",
"search": "{current_version}",
Expand All @@ -32,6 +34,7 @@
"filename": "CHANGELOG.md",
"glob": null,
"ignore_missing_version": false,
"no_regex": false,
"parse": "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?",
"replace": "**unreleased**\n**v{new_version}**",
"search": "**unreleased**",
Expand All @@ -43,6 +46,7 @@
],
"ignore_missing_version": false,
"message": "Bump version: {current_version} \u2192 {new_version}",
"no_regex": false,
"parse": "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?",
"parts": {
"major": {
Expand Down
14 changes: 8 additions & 6 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ def test_listing_with_version_part(tmp_path: Path, fixtures_path: Path):
"serialize=['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}']",
"search={current_version}",
"replace={new_version}",
"no_regex=False",
"ignore_missing_version=False",
"tag=True",
"sign_tags=False",
Expand All @@ -219,15 +220,15 @@ def test_listing_with_version_part(tmp_path: Path, fixtures_path: Path):
"{'filename': 'setup.py', 'glob': None, 'parse': "
"'(?P<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[a-z]+))?', 'serialize': "
"['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'], 'search': '{current_version}', "
"'replace': '{new_version}', 'ignore_missing_version': False}, "
"'replace': '{new_version}', 'no_regex': False, 'ignore_missing_version': False}, "
"{'filename': 'bumpversion/__init__.py', 'glob': None, 'parse': "
"'(?P<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[a-z]+))?', 'serialize': "
"['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'], 'search': '{current_version}', "
"'replace': '{new_version}', 'ignore_missing_version': False}, "
"'replace': '{new_version}', 'no_regex': False, 'ignore_missing_version': False}, "
"{'filename': 'CHANGELOG.md', 'glob': None, 'parse': "
"'(?P<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[a-z]+))?', 'serialize': "
"['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'], 'search': '**unreleased**', "
"'replace': '**unreleased**\\n**v{new_version}**', 'ignore_missing_version': False}]"
"'replace': '**unreleased**\\n**v{new_version}**', 'no_regex': False, 'ignore_missing_version': False}]"
),
}

Expand Down Expand Up @@ -257,6 +258,7 @@ def test_listing_without_version_part(tmp_path: Path, fixtures_path: Path):
"serialize=['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}']",
"search={current_version}",
"replace={new_version}",
"no_regex=False",
"ignore_missing_version=False",
"tag=True",
"sign_tags=False",
Expand All @@ -271,15 +273,15 @@ def test_listing_without_version_part(tmp_path: Path, fixtures_path: Path):
"{'filename': 'setup.py', 'glob': None, 'parse': "
"'(?P<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[a-z]+))?', 'serialize': "
"['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'], 'search': '{current_version}', "
"'replace': '{new_version}', 'ignore_missing_version': False}, "
"'replace': '{new_version}', 'no_regex': False, 'ignore_missing_version': False}, "
"{'filename': 'bumpversion/__init__.py', 'glob': None, 'parse': "
"'(?P<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[a-z]+))?', 'serialize': "
"['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'], 'search': '{current_version}', "
"'replace': '{new_version}', 'ignore_missing_version': False}, "
"'replace': '{new_version}', 'no_regex': False, 'ignore_missing_version': False}, "
"{'filename': 'CHANGELOG.md', 'glob': None, 'parse': "
"'(?P<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[a-z]+))?', 'serialize': "
"['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'], 'search': '**unreleased**', "
"'replace': '**unreleased**\\n**v{new_version}**', 'ignore_missing_version': False}]"
"'replace': '**unreleased**\\n**v{new_version}**', 'no_regex': False, 'ignore_missing_version': False}]"
),
}

Expand Down
Loading

0 comments on commit a295a32

Please sign in to comment.