diff --git a/bumpversion/files.py b/bumpversion/files.py index 60662b17..76ada18e 100644 --- a/bumpversion/files.py +++ b/bumpversion/files.py @@ -1,6 +1,7 @@ """Methods for changing files.""" import glob import logging +import re from difflib import context_diff from typing import List, MutableMapping, Optional @@ -30,6 +31,22 @@ def __init__( self.version_config = VersionConfig( self.parse, self.serialize, self.search, self.replace, version_config.part_configs ) + self._newlines: Optional[str] = None + + def get_file_contents(self) -> str: + """Return the contents of the file.""" + with open(self.path, "rt", encoding="utf-8") as f: + contents = f.read() + self._newlines = f.newlines[0] if isinstance(f.newlines, tuple) else f.newlines + return contents + + def write_file_contents(self, contents: str) -> None: + """Write the contents of the file.""" + if self._newlines is None: + _ = self.get_file_contents() + + with open(self.path, "wt", encoding="utf-8", newline=self._newlines) as f: + f.write(contents) def contains_version(self, version: Version, context: MutableMapping) -> bool: """ @@ -73,51 +90,58 @@ def contains(self, search: str) -> bool: if not search: return False - 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: %s", - search, - self.path, - lineno - (len(lookbehind) - 1), - line.rstrip(), - ) - return True + 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 return False def replace_version( self, current_version: Version, new_version: Version, context: MutableMapping, dry_run: bool = False ) -> None: """Replace the current version with the new version.""" - with open(self.path, "rt", encoding="utf-8") as f: - file_content_before = f.read() - file_new_lines = f.newlines[0] if isinstance(f.newlines, tuple) else f.newlines + file_content_before = self.get_file_contents() 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(**context) + search_for = self.version_config.search.format(**re_context) + search_for_re = self.compile_regex(search_for) replace_with = self.version_config.replace.format(**context) - file_content_after = file_content_before.replace(search_for, replace_with) + 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) 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) - file_content_after = file_content_before.replace(search_for_original_formatted, replace_with) + 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) if file_content_before != file_content_after: logger.info("%s file %s:", "Would change" if dry_run else "Changing", self.path) @@ -138,8 +162,16 @@ def replace_version( logger.info("%s file %s", "Would not change" if dry_run else "Not changing", self.path) if not dry_run: # pragma: no-coverage - with open(self.path, "wt", encoding="utf-8", newline=file_new_lines) as f: - f.write(file_content_after) + 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.""" + try: + search_for_re = re.compile(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 def __str__(self) -> str: # pragma: no-coverage return self.path diff --git a/docsrc/reference/search-and-replace-config.md b/docsrc/reference/search-and-replace-config.md index 1b2bcd75..f2f0aa12 100644 --- a/docsrc/reference/search-and-replace-config.md +++ b/docsrc/reference/search-and-replace-config.md @@ -1,10 +1,10 @@ -# Searching and replace configuration +# Search and replace configuration -Bump-my-version uses [template strings](https://docs.python.org/3/library/string.html#format-string-syntax) to search the configured files for the old or current version and replace the text with the new version. +Bump-my-version uses a combination of [template strings](https://docs.python.org/3/library/string.html#format-string-syntax) using a [formatting context](formatting-context.md) and regular expressions to search the configured files for the old or current version and replace the text with the new version. -You can configure the search or replace templates globally and within each `tool.bumpversion.files` entry in your configuration. +## Using template strings -The default search template is `{current_version}` to find the version string within the file and replace it with `{new_version}`. +Both the search and replace templates are rendered using the [formatting context](formatting-context.md). However, only the search template is also treated as a regular expression. The replacement fields available in the formatting context are enclosed in curly braces `{}`. The search and replace templates can be multiple lines, like so: @@ -34,3 +34,23 @@ replace = """ [myproject] version={new_version}""" ``` + +## Using regular expressions + +Only the search template will use [Python's regular expression syntax](https://docs.python.org/3/library/re.html#regular-expression-syntax) with minor changes. The template string is rendered using the formatting context. The resulting string is treated as a regular expression for searching unless configured otherwise. + +Curly braces (`{}`) and backslashes (`\`) must be doubled in the regular expression to escape them from the string formatting process. + +The following template: + +```text +{current_version} date-released: \\d{{4}}-\\d{{2}}-\\d{{2}} +``` + +Gets rendered to: + +```text +1\.2\.3 date-released: \d{4}-\d{2}-\d{2} +``` + +This string is used as a regular expression pattern to search. diff --git a/tests/test_files.py b/tests/test_files.py index 05cfd291..9acafa40 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -389,3 +389,51 @@ def test_ignore_missing_version(tmp_path: Path) -> None: # Assert assert version_path.read_text() == "1.2.3" + + +def test_regex_search(tmp_path: Path) -> None: + """A regex search string is found and replaced.""" + # Arrange + version_path = tmp_path / "VERSION" + version_path.write_text("Release: 1234-56-78 '1.2.3'") + + overrides = { + "current_version": "1.2.3", + "search": r"Release: \d{{4}}-\d{{2}}-\d{{2}} '{current_version}'", + "replace": r"Release {now:%Y-%m-%d} '{new_version}'", + "files": [{"filename": str(version_path)}], + } + conf, version_config, current_version = get_config_data(overrides) + new_version = current_version.bump("patch", version_config.order) + cfg_files = [files.ConfiguredFile(file_cfg, version_config) for file_cfg in conf.files] + + # Act + files.modify_files(cfg_files, current_version, new_version, get_context(conf)) + + # Assert + now = datetime.now().isoformat()[:10] + assert version_path.read_text() == f"Release {now} '1.2.4'" + + +def test_bad_regex_search(tmp_path: Path, caplog) -> None: + """A search string not meant to be a regex is still found and replaced.""" + # Arrange + version_path = tmp_path / "VERSION" + version_path.write_text("Score: A+ ( '1.2.3'") + + overrides = { + "current_version": "1.2.3", + "search": r"Score: A+ ( '{current_version}'", + "replace": r"Score: A+ ( '{new_version}'", + "files": [{"filename": str(version_path)}], + } + conf, version_config, current_version = get_config_data(overrides) + new_version = current_version.bump("patch", version_config.order) + cfg_files = [files.ConfiguredFile(file_cfg, version_config) for file_cfg in conf.files] + + # Act + files.modify_files(cfg_files, current_version, new_version, get_context(conf)) + + # Assert + assert version_path.read_text() == "Score: A+ ( '1.2.4'" + assert "Invalid regex" in caplog.text