Repository: callowayproject/bump-my-version (python)

Issue:
Traceback on ` --search` parameter with plain string
* bump-my-version version: `0.7.0`
* Python version: `3.11.4`
* Operating System: macOS 13.4

### Description

It seems a search and replace operation (without any bump), on a search string that is a constant (i.e. no reference of a version), lead to a traceback.

### What I Did

Here is a minimal example to reproduce the issue with this structure:
```shell-session
$ ls
docs.yaml  pyproject.toml
```

The local `./docs.yaml` file contains:
```yaml
url: https://github.com/kdeldycke/workflows/main/.github/update_mailmap.py
```

`./pyproject.toml` contains:
```toml
[tool.bumpversion]
current_version = "2.17.7"
allow_dirty = true
```

I use the latest CLI:
```shell-session
$ bump-my-version --version
bump-my-version, version 0.7.0
```

The configuration file is sourced correctly with the good defaults:
```shell-session
$ bump-my-version show
{'allow_dirty': True,
 'commit': False,
 'commit_args': None,
 'current_version': '2.17.7',
 'files': [],
 'message': 'Bump version: {current_version} → {new_version}',
 'parse': '(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)',
 'parts': {'major': {'first_value': None,
                     'independent': False,
                     'optional_value': None,
                     'values': None},
           'minor': {'first_value': None,
                     'independent': False,
                     'optional_value': None,
                     'values': None},
           'patch': {'first_value': None,
                     'independent': False,
                     'optional_value': None,
                     'values': None}},
 'replace': '{new_version}',
 'scm_info': SCMInfo(tool=No SCM tool, commit_sha=None, distance_to_latest_tag=None, current_version=None, dirty=None),
 'search': '{current_version}',
 'serialize': ['{major}.{minor}.{patch}'],
 'sign_tags': False,
 'tag': False,
 'tag_message': 'Bump version: {current_version} → {new_version}',
 'tag_name': 'v{new_version}'}
```

Now if I try to perform a simple search & replace operation on `./docs.yaml` with:
```shell-session
$ bump-my-version replace --dry-run --no-configured-files --search "/workflows/main/" --replace "/workflows/v{current_version}/" ./docs.yaml
```

I get the following traceback:
```pytb
Traceback (most recent call last):
  File "~/.local/bin/bump-my-version", line 8, in <module>
    sys.exit(cli())
             ^^^^^
  File ".../site-packages/click/core.py", line 1130, in __call__
    return self.main(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../site-packages/rich_click/rich_group.py", line 21, in main
    rv = super().main(*args, standalone_mode=False, **kwargs)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../site-packages/click/core.py", line 1055, in main
    rv = self.invoke(ctx)
         ^^^^^^^^^^^^^^^^
  File ".../site-packages/click/core.py", line 1657, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../site-packages/click/core.py", line 1404, in invoke
    return ctx.invoke(self.callback, **ctx.params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../site-packages/click/core.py", line 760, in invoke
    return __callback(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../site-packages/bumpversion/cli.py", line 446, in replace
    modify_files(configured_files, version, next_version, ctx, dry_run)
  File ".../site-packages/bumpversion/files.py", line 181, in modify_files
    f.replace_version(current_version, new_version, context, dry_run)
  File ".../site-packages/bumpversion/files.py", line 101, in replace_version
    context["new_version"] = self.version_config.serialize(new_version, context)
                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../site-packages/bumpversion/version_part.py", line 306, in serialize
    serialized = self._serialize(version, self._choose_serialize_format(version, context), context)
                                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../site-packages/bumpversion/version_part.py", line 271, in _choose_serialize_format
    self._serialize(version, serialize_format, context, raise_if_incomplete=True)
  File ".../site-packages/bumpversion/version_part.py", line 224, in _serialize
    for k in version:
TypeError: 'NoneType' object is not iterable
```

Pull request:
Fix search and replace options for replace.
- The `--search` and `--replace` options now completely override any other search and replace logic.

Fixes #34

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9329cd90..21bcd6fd 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,13 +1,13 @@
 repos:
   - repo: https://github.com/astral-sh/ruff-pre-commit
     # Ruff version.
-    rev: 'v0.0.276'
+    rev: 'v0.0.277'
     hooks:
       - id: ruff
         args: [--fix, --exit-non-zero-on-fix]
         exclude: test.*
   - repo: https://github.com/psf/black
-    rev: 23.3.0
+    rev: 23.7.0
     hooks:
       - id: black
   - repo: https://github.com/pre-commit/pre-commit-hooks
diff --git a/bumpversion/cli.py b/bumpversion/cli.py
index fdbf6d75..4d20cfff 100644
--- a/bumpversion/cli.py
+++ b/bumpversion/cli.py
@@ -408,8 +408,6 @@ def replace(
         new_version=new_version,
         parse=parse,
         serialize=serialize or None,
-        search=search,
-        replace=replace,
         commit=False,
         tag=False,
         sign_tags=False,
@@ -441,6 +439,6 @@ def replace(
 
     ctx = get_context(config, version, next_version)
 
-    configured_files = resolve_file_config(config.files, config.version_config)
+    configured_files = resolve_file_config(config.files, config.version_config, search, replace)
 
     modify_files(configured_files, version, next_version, ctx, dry_run)
diff --git a/bumpversion/files.py b/bumpversion/files.py
index 89b4245c..ba6b3393 100644
--- a/bumpversion/files.py
+++ b/bumpversion/files.py
@@ -2,7 +2,7 @@
 import glob
 import logging
 from difflib import context_diff
-from typing import List, MutableMapping
+from typing import List, MutableMapping, Optional
 
 from bumpversion.config import FileConfig
 from bumpversion.exceptions import VersionNotFoundError
@@ -14,12 +14,18 @@ logger = logging.getLogger(__name__)
 class ConfiguredFile:
     """A file to modify in a configured way."""
 
-    def __init__(self, file_cfg: FileConfig, version_config: VersionConfig) -> None:
+    def __init__(
+        self,
+        file_cfg: FileConfig,
+        version_config: VersionConfig,
+        search: Optional[str] = None,
+        replace: Optional[str] = None,
+    ) -> None:
         self.path = file_cfg.filename
         self.parse = file_cfg.parse or version_config.parse_regex.pattern
         self.serialize = file_cfg.serialize or version_config.serialize_formats
-        self.search = file_cfg.search or version_config.search
-        self.replace = file_cfg.replace or version_config.replace
+        self.search = search or file_cfg.search or version_config.search
+        self.replace = replace or file_cfg.replace or version_config.replace
         self.version_config = VersionConfig(
             self.parse, self.serialize, self.search, self.replace, version_config.part_configs
         )
@@ -98,7 +104,8 @@ class ConfiguredFile:
             file_new_lines = f.newlines[0] if isinstance(f.newlines, tuple) else f.newlines
 
         context["current_version"] = self.version_config.serialize(current_version, context)
-        context["new_version"] = self.version_config.serialize(new_version, context)
+        if new_version:
+            context["new_version"] = self.version_config.serialize(new_version, context)
 
         search_for = self.version_config.search.format(**context)
         replace_with = self.version_config.replace.format(**context)
@@ -127,24 +134,28 @@ class ConfiguredFile:
         else:
             logger.info("%s file %s", "Would not change" if dry_run else "Not changing", self.path)
 
-        if not dry_run:
+        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)
 
-    def __str__(self) -> str:
+    def __str__(self) -> str:  # pragma: no-coverage
         return self.path
 
-    def __repr__(self) -> str:
+    def __repr__(self) -> str:  # pragma: no-coverage
         return f"<bumpversion.ConfiguredFile:{self.path}>"
 
 
-def resolve_file_config(files: List[FileConfig], version_config: VersionConfig) -> List[ConfiguredFile]:
+def resolve_file_config(
+    files: List[FileConfig], version_config: VersionConfig, search: Optional[str] = None, replace: Optional[str] = None
+) -> List[ConfiguredFile]:
     """
     Resolve the files, searching and replacing values according to the FileConfig.
 
     Args:
         files: A list of file configurations
         version_config: How the version should be changed
+        search: The search pattern to use instead of any configured search pattern
+        replace: The replace pattern to use instead of any configured replace pattern
 
     Returns:
         A list of ConfiguredFiles
@@ -154,7 +165,7 @@ def resolve_file_config(files: List[FileConfig], version_config: VersionConfig)
         if file_cfg.glob:
             configured_files.extend(get_glob_files(file_cfg, version_config))
         else:
-            configured_files.append(ConfiguredFile(file_cfg, version_config))
+            configured_files.append(ConfiguredFile(file_cfg, version_config, search, replace))
 
     return configured_files
 
@@ -181,13 +192,17 @@ def modify_files(
         f.replace_version(current_version, new_version, context, dry_run)
 
 
-def get_glob_files(file_cfg: FileConfig, version_config: VersionConfig) -> List[ConfiguredFile]:
+def get_glob_files(
+    file_cfg: FileConfig, version_config: VersionConfig, search: Optional[str] = None, replace: Optional[str] = None
+) -> List[ConfiguredFile]:
     """
     Return a list of files that match the glob pattern.
 
     Args:
         file_cfg: The file configuration containing the glob pattern
         version_config: The version configuration
+        search: The search pattern to use instead of any configured search pattern
+        replace: The replace pattern to use instead of any configured replace pattern
 
     Returns:
         A list of resolved files according to the pattern.
@@ -196,7 +211,7 @@ def get_glob_files(file_cfg: FileConfig, version_config: VersionConfig) -> List[
     for filename_glob in glob.glob(file_cfg.glob, recursive=True):
         new_file_cfg = file_cfg.copy()
         new_file_cfg.filename = filename_glob
-        files.append(ConfiguredFile(new_file_cfg, version_config))
+        files.append(ConfiguredFile(new_file_cfg, version_config, search, replace))
     return files
 
 


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 59f89ebb..77f7e86f 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,6 +1,7 @@
 """Tests for `bumpversion` package."""
 import shutil
 import subprocess
+import traceback
 from pathlib import Path
 
 import pytest
@@ -429,3 +430,62 @@ def test_replace_specific_files(mocker, git_repo, fixtures_path):
     configured_files = call_args[0]
     assert len(configured_files) == 1
     assert configured_files[0].path == "VERSION"
+
+
+TEST_REPLACE_CONFIG = {
+    "tool": {
+        "bumpversion": {
+            "allow_dirty": True,
+            "commit": False,
+            "current_version": "2.17.7",
+            "files": [],
+            "message": "Bump version: {current_version} → {new_version}",
+            "parse": "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)",
+            "parts": {
+                "major": {},
+                "minor": {},
+                "patch": {},
+            },
+            "replace": "{new_version}",
+            "search": "{current_version}",
+            "serialize": ["{major}.{minor}.{patch}"],
+            "sign_tags": False,
+            "tag": False,
+            "tag_message": "Bump version: {current_version} → {new_version}",
+            "tag_name": "v{new_version}",
+        }
+    }
+}
+
+
+def test_replace_search_with_plain_string(tmp_path, fixtures_path):
+    """Replace should not worry if the search or replace values have version info."""
+    from tomlkit import dumps
+
+    # Arrange
+    config_path = tmp_path / "pyproject.toml"
+    config_path.write_text(dumps(TEST_REPLACE_CONFIG))
+    doc_path = tmp_path / "docs.yaml"
+    doc_path.write_text("url: https://github.com/sampleuser/workflows/main/.github/update_mailmap.py")
+
+    runner: CliRunner = CliRunner()
+    with inside_dir(tmp_path):
+        result: Result = runner.invoke(
+            cli.cli,
+            [
+                "replace",
+                "--no-configured-files",
+                "--search",
+                "/workflows/main/",
+                "--replace",
+                "/workflows/v{current_version}/",
+                "./docs.yaml",
+            ],
+        )
+
+    if result.exit_code != 0:
+        print("Here is the output:")
+        print(result.output)
+        print(traceback.print_exception(result.exc_info[1]))
+
+    assert result.exit_code == 0