Repository: callowayproject/bump-my-version
Pull request #39 (2023-07-14 18:08:30)

Provide ability to search using regular expressions in addition to substitutions


Patch:
diff --git a/bumpversion/cli.py b/bumpversion/cli.py
index 57d0b7c4..deb6e50c 100644
--- a/bumpversion/cli.py
+++ b/bumpversion/cli.py
@@ -139,6 +139,12 @@ click.rich_click.OPTION_GROUPS = {
     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,
@@ -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,
@@ -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)
@@ -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,
@@ -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,
@@ -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)
diff --git a/bumpversion/config.py b/bumpversion/config.py
index a0a24e28..b70c6897 100644
--- a/bumpversion/config.py
+++ b/bumpversion/config.py
@@ -8,6 +8,7 @@ from difflib import context_diff
 from pathlib import Path
 from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
 
+from bumpversion.ui import print_warning
 from bumpversion.utils import labels_for_format
 
 if TYPE_CHECKING:  # pragma: no-coverage
@@ -40,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]
 
 
@@ -51,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
@@ -86,6 +89,7 @@ DEFAULTS = {
     "serialize": ["{major}.{minor}.{patch}"],
     "search": "{current_version}",
     "replace": "{new_version}",
+    "no_regex": False,
     "ignore_missing_version": False,
     "tag": False,
     "sign_tags": False,
@@ -116,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:
@@ -251,6 +256,7 @@ def read_config_file(config_file: Union[str, Path, None] = None) -> Dict[str, An
     logger.info("Reading config file %s:", config_file)
     config_path = Path(config_file)
     if config_path.suffix == ".cfg":
+        print_warning("The .cfg file format is deprecated. Please use .toml instead.")
         return read_ini_file(config_path)
     elif config_path.suffix == ".toml":
         return read_toml_file(config_path)
diff --git a/bumpversion/files.py b/bumpversion/files.py
index 60662b17..254bdfe9 100644
--- a/bumpversion/files.py
+++ b/bumpversion/files.py
@@ -1,6 +1,8 @@
 """Methods for changing files."""
 import glob
 import logging
+import re
+from copy import deepcopy
 from difflib import context_diff
 from typing import List, MutableMapping, Optional
 
@@ -26,10 +28,27 @@ class ConfiguredFile:
         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
         )
+        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:
         """
@@ -45,7 +64,7 @@ class ConfiguredFile:
         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
@@ -58,66 +77,55 @@ class ConfiguredFile:
         # 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
 
-        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
+        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(
         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)
 
-        search_for = self.version_config.search.format(**context)
+        search_for = self.get_search_pattern(context)
         replace_with = self.version_config.replace.format(**context)
 
-        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)
-            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)
@@ -138,8 +146,27 @@ class ConfiguredFile:
             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 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(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.", 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
diff --git a/docsrc/reference/search-and-replace-config.md b/docsrc/reference/search-and-replace-config.md
index 1b2bcd75..f70748d4 100644
--- a/docsrc/reference/search-and-replace-config.md
+++ b/docsrc/reference/search-and-replace-config.md
@@ -1,10 +1,18 @@
-# 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.
+Bump My Version falls back to using a simple string search if the search template is not a valid regular expression or if the `no-regex` flag is `True`. The search template is always rendered using the formatting context. The basic logic is:
 
-The default search template is `{current_version}` to find the version string within the file and replace it with `{new_version}`.
+1. Escape the formatting context for use in a regular expression.
+2. Render the search string using the escaped formatting context.
+3. Attempt to compile the rendered search string as a regular expression.
+4. If the rendered search string is a valid regular expression, use it.
+5. If the rendered search string is _not_ a valid regular expression or the `no-regex` flag is `True`, use the search string rendered with the unescaped context.
+
+## Using template strings
+
+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 +42,47 @@ 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.
+
+## Regular expression special characters
+
+The `.`, `^`, `$`, `*`, `+`, `?`, `()`, `[]`, `{}`, `\`, `|` characters are special characters in regular expressions. If your search string contains these characters, you must escape them with a backslash (`\`) to treat them as literal characters or set the `no-regex` flag to `True`.
+
+For example, if you are looking for this string in a file:
+
+```text
+[Unreleased] 2023-07-17
+```
+
+and you use this search pattern:
+
+```text
+[Unreleased] \\d{{4}}-\\d{{2}}-\\d{{2}}
+```
+
+Bump My Version will not find the string. While the rendered regular expression `[Unreleased] \d{4}-\d{2}-\d{2}` is valid, it is not searching for the literal `[Unreleased]`. Instead, it matches a single character in the list `U`, `n`, `r`, `e`, `l`, `a`, `s`, `d`.
+
+You must escape the `[` and `]` to treat them as literal characters:
+
+```text
+\[Unreleased\] \\d{{4}}-\\d{{2}}-\\d{{2}}
+```


Test patch:
diff --git a/tests/fixtures/basic_cfg_expected.txt b/tests/fixtures/basic_cfg_expected.txt
index d9a7366b..97d0203a 100644
--- a/tests/fixtures/basic_cfg_expected.txt
+++ b/tests/fixtures/basic_cfg_expected.txt
@@ -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}',
@@ -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}',
@@ -21,6 +23,7 @@
            {'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**',
@@ -28,6 +31,7 @@
                           '{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,
diff --git a/tests/fixtures/basic_cfg_expected.yaml b/tests/fixtures/basic_cfg_expected.yaml
index 9a1ab759..fd991f24 100644
--- a/tests/fixtures/basic_cfg_expected.yaml
+++ b/tests/fixtures/basic_cfg_expected.yaml
@@ -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}"
@@ -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}"
@@ -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**"
@@ -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:
diff --git a/tests/fixtures/basic_cfg_expected_full.json b/tests/fixtures/basic_cfg_expected_full.json
index cfaac4ae..91421eef 100644
--- a/tests/fixtures/basic_cfg_expected_full.json
+++ b/tests/fixtures/basic_cfg_expected_full.json
@@ -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}",
@@ -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}",
@@ -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**",
@@ -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": {
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 1cc658e8..3e1cd122 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -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",
@@ -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}]"
         ),
     }
 
@@ -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",
@@ -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}]"
         ),
     }
 
diff --git a/tests/test_files.py b/tests/test_files.py
index 05cfd291..7fb45842 100644
--- a/tests/test_files.py
+++ b/tests/test_files.py
@@ -221,7 +221,10 @@ def test_raises_correct_missing_version_string(tmp_path: Path):
         assert e.message.startswith("Did not find '1;3;1;0;rc;build.1031'")
 
 
-def test_search_replace_to_avoid_updating_unconcerned_lines(tmp_path: Path):
+def test_search_replace_to_avoid_updating_unconcerned_lines(tmp_path: Path, caplog):
+    import logging
+
+    caplog.set_level(logging.DEBUG)
     req_path = tmp_path / "requirements.txt"
     req_path.write_text("Django>=1.5.6,<1.6\nMyProject==1.5.6")
 
@@ -255,6 +258,7 @@ def test_search_replace_to_avoid_updating_unconcerned_lines(tmp_path: Path):
                 "filename": str(changelog_path),
                 "search": "## [Unreleased]",
                 "replace": "## [Unreleased]\n\n## [{new_version}] - {utcnow:%Y-%m-%d}",
+                "no_regex": True,
             },
         ],
     }
@@ -283,7 +287,7 @@ def test_search_replace_to_avoid_updating_unconcerned_lines(tmp_path: Path):
       standardized open source project CHANGELOG.
     """
     )
-
+    print(caplog.text)
     assert req_path.read_text() == "Django>=1.5.6,<1.6\nMyProject==1.6.0"
     assert changelog_path.read_text() == expected_chglog
 
@@ -389,3 +393,75 @@ 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_regex_search_with_escaped_chars(tmp_path: Path) -> None:
+    """A search that uses special characters is not treated as a regex."""
+    # Arrange
+    version_path = tmp_path / "VERSION"
+    version_path.write_text("## [Release] 1.2.3 1234-56-78")
+
+    overrides = {
+        "current_version": "1.2.3",
+        "search": r"## \[Release\] {current_version} \d{{4}}-\d{{2}}-\d{{2}}",
+        "replace": r"## [Release] {new_version} {now:%Y-%m-%d}",
+        "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] 1.2.4 {now}"
+
+
+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