Repository: c4urself/bump2version

Issue:
Don't fall back to original_version if `search` pattern is not found
This is one of the areas where bump2version does not work like I expect it to.

If for any reason (user error) the `search` pattern is not found, it simply assumes that it can replace `{current_version}` with the new version.

This can lead to incorrect changes (broken files!) as demonstrated in #126.  I often encounter this while developing `search` patterns and when using custom `replace` patterns.

I think this 'fall-back feature' does more harm than good, and we should drop it.

Given a file version.txt

    release = "1.0.0"

And a config

    [bumpversion]
    current_version = 1.0.0

    [bumpversion:file:version.txt]
    search = current_release = "{current_version}"
    replace = current_release = "{new_version}"

Doing `bumpversion patch` produces this without any warning:

    release = "current_release = 1.0.1"

This is *wrong*!

Discussion:

Patch:
diff --git a/bumpversion/exceptions.py b/bumpversion/exceptions.py
index ecce8b6..a635316 100644
--- a/bumpversion/exceptions.py
+++ b/bumpversion/exceptions.py
@@ -1,18 +1,28 @@
-class IncompleteVersionRepresentationException(Exception):
+
+
+class BumpVersionException(Exception):
+    """Custom base class for all BumpVersion exception types."""
+
+
+class IncompleteVersionRepresentationException(BumpVersionException):
     def __init__(self, message):
         self.message = message
 
 
-class MissingValueForSerializationException(Exception):
+class MissingValueForSerializationException(BumpVersionException):
     def __init__(self, message):
         self.message = message
 
 
-class WorkingDirectoryIsDirtyException(Exception):
+class WorkingDirectoryIsDirtyException(BumpVersionException):
     def __init__(self, message):
         self.message = message
 
 
-class MercurialDoesNotSupportSignedTagsException(Exception):
+class MercurialDoesNotSupportSignedTagsException(BumpVersionException):
     def __init__(self, message):
         self.message = message
+
+
+class VersionNotFoundException(BumpVersionException):
+    """A version number was not found in a source file."""
diff --git a/bumpversion/utils.py b/bumpversion/utils.py
index 4f5be53..f872f23 100644
--- a/bumpversion/utils.py
+++ b/bumpversion/utils.py
@@ -4,6 +4,7 @@ import io
 import logging
 import os
 
+from bumpversion.exceptions import VersionNotFoundException
 
 logger = logging.getLogger(__name__)
 
@@ -33,30 +34,47 @@ def prefixed_environ():
 
 
 class ConfiguredFile:
+
     def __init__(self, path, versionconfig):
         self.path = path
         self._versionconfig = versionconfig
 
     def should_contain_version(self, version, context):
+        """
+        Raise VersionNotFound if the version number isn't present in this file.
 
+        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)
 
-        serialized_version = self._versionconfig.search.format(**context)
-
-        if self.contains(serialized_version):
+        if self.contains(search_expression):
             return
 
-        msg = "Did not find '{}' or '{}' in file {}".format(
-            version.original, serialized_version, self.path
-        )
+        # the `search` pattern did not match, but the original supplied
+        # version number (representing the same version part values) might
+        # match instead.
+
+        # 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}"
 
-        if version.original:
-            assert self.contains(version.original), msg
+        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
 
-        assert False, msg
+        # version not found
+        raise VersionNotFoundException(
+            "Did not find '{}' in file: '{}'".format(
+                search_expression, self.path
+            )
+        )
 
     def contains(self, search):
+        if not search:
+            return False
+
         with open(self.path, "rt", encoding="utf-8") as f:
             search_lines = search.splitlines()
             lookbehind = []


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index bf912f9..71ef3a0 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -15,7 +15,7 @@ import pytest
 from testfixtures import LogCapture
 
 import bumpversion
-from bumpversion.exceptions import WorkingDirectoryIsDirtyException
+from bumpversion import exceptions
 from bumpversion.cli import DESCRIPTION, main, split_args_in_optional_and_positional
 
 
@@ -169,7 +169,7 @@ def test_usage_string(tmpdir, capsys):
     assert EXPECTED_USAGE in out
 
 
-def test_usage_string_fork(tmpdir, capsys):
+def test_usage_string_fork(tmpdir):
     tmpdir.chdir()
 
     if platform.system() == "Windows":
@@ -408,7 +408,7 @@ def test_dirty_work_dir(tmpdir, vcs):
     vcs_name = "Mercurial" if vcs == "hg" else "Git"
     vcs_output = "A dirty" if vcs == "hg" else "A  dirty"
 
-    with pytest.raises(WorkingDirectoryIsDirtyException):
+    with pytest.raises(exceptions.WorkingDirectoryIsDirtyException):
         with LogCapture() as log_capture:
             main(['patch', '--current-version', '1', '--new-version', '2', 'file7'])
 
@@ -746,7 +746,7 @@ def test_tag_name(tmpdir, vcs):
     assert b'ReleasedVersion-31.1.2' in tag_out
 
 
-def test_message_from_config_file(tmpdir, capsys, vcs):
+def test_message_from_config_file(tmpdir, vcs):
     tmpdir.chdir()
     check_call([vcs, "init"])
     tmpdir.join("VERSION").write("400.0.0")
@@ -772,7 +772,7 @@ tag_name: from-{current_version}-to-{new_version}""")
     assert b'from-400.0.0-to-401.0.0' in tag_out
 
 
-def test_all_parts_in_message_and_serialize_and_tag_name_from_config_file(tmpdir, capsys, vcs):
+def test_all_parts_in_message_and_serialize_and_tag_name_from_config_file(tmpdir, vcs):
     """
     Ensure that major/minor/patch *and* custom parts can be used everywhere.
 
@@ -811,7 +811,7 @@ tag_name: from-{current_version}-aka-{current_major}.{current_minor}.{current_pa
     assert b'from-400.1.2.101-aka-400.1.2-custom-101-to-401.2.3.102-aka-401.2.3-custom-102' in tag_out
 
 
-def test_all_parts_in_replace_from_config_file(tmpdir, capsys, vcs):
+def test_all_parts_in_replace_from_config_file(tmpdir, vcs):
     """
     Ensure that major/minor/patch *and* custom parts can be used in 'replace'.
     """
@@ -931,7 +931,7 @@ except ImportError:
 
 @pytest.mark.xfail(not config_parser_handles_utf8,
                    reason="old ConfigParser uses non-utf-8-strings internally")
-def test_utf8_message_from_config_file(tmpdir, capsys, vcs):
+def test_utf8_message_from_config_file(tmpdir, vcs):
     tmpdir.chdir()
     check_call([vcs, "init"])
     tmpdir.join("VERSION").write("500.0.0")
@@ -952,7 +952,7 @@ message = Nová verze: {current_version} ☃, {new_version} ☀
     assert expected_new_config.encode('utf-8') == tmpdir.join(".bumpversion.cfg").read(mode='rb')
 
 
-def test_utf8_message_from_config_file(tmpdir, capsys, vcs):
+def test_utf8_message_from_config_file(tmpdir, vcs):
     tmpdir.chdir()
     check_call([vcs, "init"])
     tmpdir.join("VERSION").write("10.10.0")
@@ -1082,7 +1082,7 @@ parse = (?P<major>\d+)\.(?P<minor>\d+)(\.(?P<patch>\d+))?
     assert '0.6.1' == tmpdir.join("fileD").read()
 
 
-def test_log_no_config_file_info_message(tmpdir, capsys):
+def test_log_no_config_file_info_message(tmpdir):
     tmpdir.chdir()
 
     tmpdir.join("a_file.txt").write("1.0.0")
@@ -1140,7 +1140,7 @@ def test_log_invalid_regex_exit(tmpdir):
     )
 
 
-def test_complex_info_logging(tmpdir, capsys):
+def test_complex_info_logging(tmpdir):
     tmpdir.join("fileE").write("0.4")
     tmpdir.chdir()
 
@@ -1337,7 +1337,7 @@ def test_no_list_no_stdout(tmpdir, vcs):
     assert out == ""
 
 
-def test_bump_non_numeric_parts(tmpdir, capsys):
+def test_bump_non_numeric_parts(tmpdir):
     tmpdir.join("with_pre_releases.txt").write("1.5.dev")
     tmpdir.chdir()
 
@@ -1397,7 +1397,7 @@ def test_optional_value_from_documentation(tmpdir):
     assert '1' == tmpdir.join("optional_value_from_doc.txt").read()
 
 
-def test_python_pre_release_release_post_release(tmpdir, capsys):
+def test_python_pre_release_release_post_release(tmpdir):
     tmpdir.join("python386.txt").write("1.0a")
     tmpdir.chdir()
 
@@ -1479,7 +1479,7 @@ def test_part_first_value(tmpdir):
     assert '1.1.0' == tmpdir.join("the_version.txt").read()
 
 
-def test_multi_file_configuration(tmpdir, capsys):
+def test_multi_file_configuration(tmpdir):
     tmpdir.join("FULL_VERSION.txt").write("1.0.3")
     tmpdir.join("MAJOR_VERSION.txt").write("1")
 
@@ -1506,7 +1506,7 @@ def test_multi_file_configuration(tmpdir, capsys):
     assert '2' in tmpdir.join("MAJOR_VERSION.txt").read()
 
 
-def test_multi_file_configuration2(tmpdir, capsys):
+def test_multi_file_configuration2(tmpdir):
     tmpdir.join("setup.cfg").write("1.6.6")
     tmpdir.join("README.txt").write("MyAwesomeSoftware(TM) v1.6")
     tmpdir.join("BUILD_NUMBER").write("1.6.6+joe+38943")
@@ -1555,7 +1555,7 @@ def test_multi_file_configuration2(tmpdir, capsys):
     assert '1.7.1+bob+38945' in tmpdir.join("BUILD_NUMBER").read()
 
 
-def test_search_replace_to_avoid_updating_unconcerned_lines(tmpdir, capsys):
+def test_search_replace_to_avoid_updating_unconcerned_lines(tmpdir):
     tmpdir.chdir()
 
     tmpdir.join("requirements.txt").write("Django>=1.5.6,<1.6\nMyProject==1.5.6")
@@ -1596,7 +1596,7 @@ def test_search_replace_to_avoid_updating_unconcerned_lines(tmpdir, capsys):
     assert 'Django>=1.5.6' in tmpdir.join("requirements.txt").read()
 
 
-def test_search_replace_expanding_changelog(tmpdir, capsys):
+def test_search_replace_expanding_changelog(tmpdir):
     tmpdir.chdir()
 
     tmpdir.join("CHANGELOG.md").write(dedent("""
@@ -1654,7 +1654,42 @@ def test_search_replace_expanding_changelog(tmpdir, capsys):
     assert postdate in tmpdir.join("CHANGELOG.md").read()
 
 
-def test_search_replace_cli(tmpdir, capsys):
+def test_non_matching_search_does_not_modify_file(tmpdir):
+    tmpdir.chdir()
+
+    changelog_content = dedent("""
+    # Unreleased
+    
+    * bullet point A
+    
+    # Release v'older' (2019-09-17)
+    
+    * bullet point B
+    """)
+
+    config_content = dedent("""
+      [bumpversion]
+      current_version = 1.0.3
+
+      [bumpversion:file:CHANGELOG.md]
+      search = Not-yet-released
+      replace = Release v{new_version} ({now:%Y-%m-%d})
+    """)
+
+    tmpdir.join("CHANGELOG.md").write(changelog_content)
+    tmpdir.join(".bumpversion.cfg").write(config_content)
+
+    with pytest.raises(
+            exceptions.VersionNotFoundException,
+            match="Did not find 'Not-yet-released' in file: 'CHANGELOG.md'"
+    ):
+        main(['patch', '--verbose'])
+
+    assert changelog_content == tmpdir.join("CHANGELOG.md").read()
+    assert config_content in tmpdir.join(".bumpversion.cfg").read()
+
+
+def test_search_replace_cli(tmpdir):
     tmpdir.join("file89").write("My birthday: 3.5.98\nCurrent version: 3.5.98")
     tmpdir.chdir()
     main([
@@ -1829,7 +1864,7 @@ def test_configparser_empty_lines_in_values(tmpdir):
     """) == tmpdir.join("CHANGES.rst").read()
 
 
-def test_regression_tag_name_with_hyphens(tmpdir, capsys, git):
+def test_regression_tag_name_with_hyphens(tmpdir, git):
     tmpdir.chdir()
     tmpdir.join("some_source.txt").write("2014.10.22")
     check_call([git, "init"])
@@ -1873,7 +1908,7 @@ message = XXX
     assert "Failed to run" in caplog.text
 
 
-def test_regression_characters_after_last_label_serialize_string(tmpdir, capsys):
+def test_regression_characters_after_last_label_serialize_string(tmpdir):
     tmpdir.chdir()
     tmpdir.join("bower.json").write('''
     {
@@ -1894,7 +1929,7 @@ def test_regression_characters_after_last_label_serialize_string(tmpdir, capsys)
     main(['patch', 'bower.json'])
 
 
-def test_regression_dont_touch_capitalization_of_keys_in_config(tmpdir, capsys):
+def test_regression_dont_touch_capitalization_of_keys_in_config(tmpdir):
     tmpdir.chdir()
     tmpdir.join("setup.cfg").write(dedent("""
     [bumpversion]
@@ -1915,7 +1950,7 @@ def test_regression_dont_touch_capitalization_of_keys_in_config(tmpdir, capsys):
     """).strip() == tmpdir.join("setup.cfg").read().strip()
 
 
-def test_regression_new_version_cli_in_files(tmpdir, capsys):
+def test_regression_new_version_cli_in_files(tmpdir):
     """
     Reported here: https://github.com/peritus/bumpversion/issues/60
     """