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

Issue:
ignore_missing_version not honored when set in config file
* bump-my-version version: 0.17.4
* Python version: 3.10.13
* Operating System: Ubuntu 22.04.4 LTS

### Description

I am trying to setup version bumping for one of our projects. The project contains a submodule that we don't want to bump and the submodule's pyproject.toml doesn't list a version. 

The ignore-missing-version flag is not getting honored from the config file but does work via command line.

I am also unable to use the excluded_paths argument at all. There isn't really any documentation for using that, how is it meant to be used? I can't get it to work from the command line or from the config file.

### What I Did

I have tried applying excluded_paths both in the main config section and in the specific files group in the config file as well as via a CLI argument. The setting is never honored and bumping aborts when reaching the submodule directory.

As an alternative to outright ignoring the submodule's presence, I then tried utilizing the lack of version in the submodule's pyproject.toml file to skip over it that way. The flag is honored when used as a CLI argument but not when set in the config file. 

This is the relevant section of my config file
```
[tool.bumpversion]
current_version = "2.8.2.3rc1"
parse = """(?x)
    (?P<major>0|[1-9]\\d*)\\.
    (?P<minor>0|[1-9]\\d*)\\.
    (?P<patch>0|[1-9]\\d*)
    (?:
        \\.(?P<smallpatch>0|[1-9]\\d*)
    )?                                  # smallpatch section is optional
    (?:
        (?P<pre_l>[a-zA-Z-]+)           # pre-release label
        (?P<pre_n>[1-9]\\d*)            # pre-release version number
    )?                                  # pre-release section is optional
"""
serialize = [
    "{major}.{minor}.{patch}{pre_l}{pre_n}",
    "{major}.{minor}.{patch}.{smallpatch}{pre_l}{pre_n}",
    "{major}.{minor}.{patch}.{smallpatch}",
    "{major}.{minor}.{patch}",
]
allow_dirty = true
ignore_missing_version = true

[tool.bumpversion.parts.pre_l]
values = ["dev", "rc", "final"]
optional_value = "final"

[[tool.bumpversion.files]]
glob = "<location>/*/pyproject.toml"
search = "version = \"{current_version}\""
replace = "version = \"{new_version}\""
```
when I run this I still get the 'Did not find' error ```
❯ bump-my-version bump smallpatch --current-version=2.8.2.3rc1 --config-file=./.bumpversion.toml --dry-run```

>                                                                                                                                                                                                                                     
>  Usage: bump-my-version bump [OPTIONS] [ARGS]...                                                                                                                                                                                    
>                                                                                                                                                                                                                                     
>  Try 'bump-my-version bump -h' for help                                                                                                                                                                                             
> ╭─ Error ──────────────────────────────────────╮
> │ Did not find 'version = "2.8.2.3rc1"' in file: 'location/submodule/pyproject.toml'                                                                                                                              │
> ╰──────────────────────────────────────────╯

but when I run that same command with the CLI flag, it works
`❯ bump-my-version bump smallpatch --current-version=2.8.2.3rc1 --config-file=./.bumpversion.toml --dry-run --ignore-missing-version`


The config file hasn't changed between those two tries.
Oddly, if I run `bump-my-version show` I see the field set correctly `'ignore_missing_version': True,` but running without the command line flag always fails.

Pull request:
Fixed --ignore-missing-version and --ignore-missing-files options.
The CLI options were defaulting to `False` when missing. This overrode the configuration.

Fixes #140

Patch:
diff --git a/bumpversion/cli.py b/bumpversion/cli.py
index 37064f58..daee9878 100644
--- a/bumpversion/cli.py
+++ b/bumpversion/cli.py
@@ -163,14 +163,14 @@ click.rich_click.OPTION_GROUPS = {
     ),
 )
 @click.option(
-    "--ignore-missing-files",
-    is_flag=True,
+    "--ignore-missing-files/--no-ignore-missing-files",
+    default=None,
     envvar="BUMPVERSION_IGNORE_MISSING_FILES",
     help="Ignore any missing files when searching and replacing in files.",
 )
 @click.option(
-    "--ignore-missing-version",
-    is_flag=True,
+    "--ignore-missing-version/--no-ignore-missing-version",
+    default=None,
     envvar="BUMPVERSION_IGNORE_MISSING_VERSION",
     help="Ignore any Version Not Found errors when searching and replacing in files.",
 )
diff --git a/bumpversion/config/__init__.py b/bumpversion/config/__init__.py
index 98718d19..d1498094 100644
--- a/bumpversion/config/__init__.py
+++ b/bumpversion/config/__init__.py
@@ -17,7 +17,7 @@ logger = get_indented_logger(__name__)
 DEFAULTS = {
     "current_version": None,
     "parse": r"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)",
-    "serialize": ["{major}.{minor}.{patch}"],
+    "serialize": ("{major}.{minor}.{patch}",),
     "search": "{current_version}",
     "replace": "{new_version}",
     "regex": False,


Test patch:
diff --git a/tests/test_config/test_create.py b/tests/test_config/test_create.py
index 1e269565..b366fe22 100644
--- a/tests/test_config/test_create.py
+++ b/tests/test_config/test_create.py
@@ -105,7 +105,27 @@ class TestCreateConfiguration:
         default_config["current_version"] = "0.1.0"
         default_config["commit_args"] = ""
 
-        assert destination_config["tool"]["bumpversion"] == default_config
+        assert destination_config["tool"]["bumpversion"]["allow_dirty"] == default_config["allow_dirty"]
+        assert destination_config["tool"]["bumpversion"]["commit"] == default_config["commit"]
+        assert destination_config["tool"]["bumpversion"]["commit_args"] == default_config["commit_args"]
+        assert destination_config["tool"]["bumpversion"]["current_version"] == default_config["current_version"]
+        assert (
+            destination_config["tool"]["bumpversion"]["ignore_missing_files"] == default_config["ignore_missing_files"]
+        )
+        assert (
+            destination_config["tool"]["bumpversion"]["ignore_missing_version"]
+            == default_config["ignore_missing_version"]
+        )
+        assert destination_config["tool"]["bumpversion"]["message"] == default_config["message"]
+        assert destination_config["tool"]["bumpversion"]["parse"] == default_config["parse"]
+        assert destination_config["tool"]["bumpversion"]["regex"] == default_config["regex"]
+        assert destination_config["tool"]["bumpversion"]["replace"] == default_config["replace"]
+        assert destination_config["tool"]["bumpversion"]["search"] == default_config["search"]
+        assert destination_config["tool"]["bumpversion"]["serialize"] == list(default_config["serialize"])
+        assert destination_config["tool"]["bumpversion"]["sign_tags"] == default_config["sign_tags"]
+        assert destination_config["tool"]["bumpversion"]["tag"] == default_config["tag"]
+        assert destination_config["tool"]["bumpversion"]["tag_message"] == default_config["tag_message"]
+        assert destination_config["tool"]["bumpversion"]["tag_name"] == default_config["tag_name"]
 
     def test_prompt_true_asks_questions_and_updates_result(self, tmp_path: Path, default_config: dict, mocker):
         """If prompt is True, the user should be prompted for the configuration."""
@@ -123,5 +143,25 @@ class TestCreateConfiguration:
 
         default_config.update(answer)
 
-        assert destination_config["tool"]["bumpversion"] == default_config
+        assert destination_config["tool"]["bumpversion"]["allow_dirty"] == default_config["allow_dirty"]
+        assert destination_config["tool"]["bumpversion"]["commit"] == default_config["commit"]
+        assert destination_config["tool"]["bumpversion"]["commit_args"] == default_config["commit_args"]
+        assert destination_config["tool"]["bumpversion"]["current_version"] == default_config["current_version"]
+        assert (
+            destination_config["tool"]["bumpversion"]["ignore_missing_files"] == default_config["ignore_missing_files"]
+        )
+        assert (
+            destination_config["tool"]["bumpversion"]["ignore_missing_version"]
+            == default_config["ignore_missing_version"]
+        )
+        assert destination_config["tool"]["bumpversion"]["message"] == default_config["message"]
+        assert destination_config["tool"]["bumpversion"]["parse"] == default_config["parse"]
+        assert destination_config["tool"]["bumpversion"]["regex"] == default_config["regex"]
+        assert destination_config["tool"]["bumpversion"]["replace"] == default_config["replace"]
+        assert destination_config["tool"]["bumpversion"]["search"] == default_config["search"]
+        assert destination_config["tool"]["bumpversion"]["serialize"] == list(default_config["serialize"])
+        assert destination_config["tool"]["bumpversion"]["sign_tags"] == default_config["sign_tags"]
+        assert destination_config["tool"]["bumpversion"]["tag"] == default_config["tag"]
+        assert destination_config["tool"]["bumpversion"]["tag_message"] == default_config["tag_message"]
+        assert destination_config["tool"]["bumpversion"]["tag_name"] == default_config["tag_name"]
         assert mocked_questionary.form.return_value.ask.call_count == 1
diff --git a/tests/test_config/test_utils.py b/tests/test_config/test_utils.py
new file mode 100644
index 00000000..2e3d8551
--- /dev/null
+++ b/tests/test_config/test_utils.py
@@ -0,0 +1,115 @@
+"""Tests of the configuration utilities."""
+
+from pathlib import Path
+from typing import Any
+
+import tomlkit
+import pytest
+from pytest import param
+
+from bumpversion.config.utils import get_all_file_configs, get_all_part_configs, resolve_glob_files
+from bumpversion.config.models import FileChange
+from bumpversion.config import DEFAULTS
+from tests.conftest import inside_dir
+
+
+def write_config(tmp_path: Path, overrides: dict) -> Path:
+    """Write a configuration file."""
+    config_file = tmp_path / ".bumpversion.toml"
+    defaults = DEFAULTS.copy()
+    defaults.pop("parts")
+    defaults.pop("scm_info")
+    defaults["current_version"] = defaults["current_version"] or "1.2.3"
+    defaults["commit_args"] = ""
+    config = {"tool": {"bumpversion": {**defaults, **overrides}}}
+    config_file.write_text(tomlkit.dumps(config))
+    return config_file
+
+
+class TestGetAllFileConfigs:
+    """Tests for the get_all_file_configs function."""
+
+    def test_uses_defaults_for_missing_keys(self, tmp_path: Path):
+        """Test the get_all_file_configs function."""
+        config_file = write_config(tmp_path, {"files": [{"filename": "setup.cfg"}]})
+        config_dict = tomlkit.loads(config_file.read_text())["tool"]["bumpversion"]
+        file_configs = get_all_file_configs(config_dict)
+
+        for key in FileChange.model_fields.keys():
+            global_key = key if key != "ignore_missing_file" else "ignore_missing_files"
+            if key not in ["filename", "glob", "key_path"]:
+                file_val = getattr(file_configs[0], key)
+                assert file_val == DEFAULTS[global_key]
+
+    @pytest.mark.parametrize(
+        ["attribute", "global_value", "file_value"],
+        [
+            param("parse", DEFAULTS["parse"], r"v(?P<major>\d+)", id="parse"),
+            param("serialize", DEFAULTS["serialize"], ("v{major}",), id="serialize"),
+            param("search", DEFAULTS["search"], "v{current_version}", id="search"),
+            param("replace", DEFAULTS["replace"], "v{new_version}", id="replace"),
+            param("ignore_missing_version", True, False, id="ignore_missing_version"),
+            param("ignore_missing_files", True, False, id="ignore_missing_files"),
+            param("regex", True, False, id="regex"),
+        ],
+    )
+    def test_overrides_defaults(self, tmp_path: Path, attribute: str, global_value: Any, file_value: Any):
+        """Configured attributes in the file should override global options."""
+        file_attribute = attribute if attribute != "ignore_missing_files" else "ignore_missing_file"
+        config_file = write_config(
+            tmp_path,
+            {
+                attribute: global_value,
+                "files": [
+                    {
+                        "filename": "setup.cfg",
+                        file_attribute: file_value,
+                    }
+                ],
+            },
+        )
+        config_dict = tomlkit.loads(config_file.read_text())["tool"]["bumpversion"]
+        file_configs = get_all_file_configs(config_dict)
+        assert len(file_configs) == 1
+        assert file_configs[0].filename == "setup.cfg"
+        assert getattr(file_configs[0], file_attribute) == file_value
+
+
+class TestResolveGlobFiles:
+    """Tests for the resolve_glob_files function."""
+
+    def test_all_attributes_are_copied(self, tmp_path: Path):
+        """Test that all attributes are copied."""
+        file1 = tmp_path.joinpath("setup.cfg")
+        file2 = tmp_path.joinpath("subdir/setup.cfg")
+        file1.touch()
+        file2.parent.mkdir()
+        file2.touch()
+
+        file_cfg = FileChange(
+            filename=None,
+            glob="**/*.cfg",
+            key_path=None,
+            parse=r"v(?P<major>\d+)",
+            serialize=("v{major}",),
+            search="v{current_version}",
+            replace="v{new_version}",
+            ignore_missing_version=True,
+            ignore_missing_file=True,
+            regex=True,
+        )
+        with inside_dir(tmp_path):
+            resolved_files = resolve_glob_files(file_cfg)
+
+        assert len(resolved_files) == 2
+        for resolved_file in resolved_files:
+            assert resolved_file.filename.endswith("setup.cfg")
+            assert resolved_file.glob is None
+            assert resolved_file.key_path is None
+            assert resolved_file.parse == r"v(?P<major>\d+)"
+            assert resolved_file.serialize == ("v{major}",)
+            assert resolved_file.search == "v{current_version}"
+            assert resolved_file.replace == "v{new_version}"
+            assert resolved_file.ignore_missing_version is True
+            assert resolved_file.ignore_missing_file is True
+            assert resolved_file.regex is True