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

Issue:
Bumping version adds extra whitespace to `pyproject.toml`
* bump-my-version version: 0.16.1
* Python version: 3.12.1
* Operating System: Ubuntu 22.04 LTS

### Description

The newest version of `bump-my-version` adds an extra newline to the `pyproject.toml` file when bumping the version. This is causing issues for me with linters and code formatting tools. 

### What I Did

```
$ bump-my-version bump build
```

### Notes

See line 152 here: https://github.com/Ouranosinc/xclim/commit/a368fa4cbb4f9f6842cceb34d74d1542adb490fb

Pull request:
Fixed extra whitespace added when updating pyproject.toml
- Removed dotted-notation from requirements. There is an issue on how dotted-notation sets values in the TOMLkit data structure.

- Added `get_nested_value` and `set_nested_value` as replacements for dotted-notation.

Fixes #123 

Patch:
diff --git a/bumpversion/files.py b/bumpversion/files.py
index aee9c505..83bab62a 100644
--- a/bumpversion/files.py
+++ b/bumpversion/files.py
@@ -9,6 +9,7 @@ from typing import Dict, List, MutableMapping, Optional
 from bumpversion.config.models import FileChange, VersionPartConfig
 from bumpversion.exceptions import VersionNotFoundError
 from bumpversion.ui import get_indented_logger
+from bumpversion.utils import get_nested_value, set_nested_value
 from bumpversion.version_part import Version, VersionConfig
 
 logger = get_indented_logger(__name__)
@@ -327,11 +328,10 @@ class DataFileUpdater:
         self, search_for: re.Pattern, raw_search_pattern: str, replace_with: str, dry_run: bool = False
     ) -> None:
         """Update a TOML file."""
-        import dotted
         import tomlkit
 
         toml_data = tomlkit.parse(self.path.read_text())
-        value_before = dotted.get(toml_data, self.file_change.key_path)
+        value_before = get_nested_value(toml_data, self.file_change.key_path)
 
         if value_before is None:
             raise KeyError(f"Key path '{self.file_change.key_path}' does not exist in {self.path}")
@@ -347,5 +347,6 @@ class DataFileUpdater:
         if dry_run:
             return
 
-        dotted.update(toml_data, self.file_change.key_path, new_value)
+        set_nested_value(toml_data, new_value, self.file_change.key_path)
+
         self.path.write_text(tomlkit.dumps(toml_data))
diff --git a/bumpversion/utils.py b/bumpversion/utils.py
index 5243b1a0..ea9b68d4 100644
--- a/bumpversion/utils.py
+++ b/bumpversion/utils.py
@@ -74,3 +74,60 @@ def get_context(
 def get_overrides(**kwargs) -> dict:
     """Return a dictionary containing only the overridden key-values."""
     return {key: val for key, val in kwargs.items() if val is not None}
+
+
+def get_nested_value(d: dict, path: str) -> Any:
+    """
+    Retrieves the value of a nested key in a dictionary based on the given path.
+
+    Args:
+        d: The dictionary to search.
+        path: A string representing the path to the nested key, separated by periods.
+
+    Returns:
+        The value of the nested key.
+
+    Raises:
+        KeyError: If a key in the path does not exist.
+        ValueError: If an element in the path is not a dictionary.
+    """
+    keys = path.split(".")
+    current_element = d
+
+    for key in keys:
+        if not isinstance(current_element, dict):
+            raise ValueError(f"Element at '{'.'.join(keys[:keys.index(key)])}' is not a dictionary")
+
+        if key not in current_element:
+            raise KeyError(f"Key '{key}' not found at '{'.'.join(keys[:keys.index(key)])}'")
+
+        current_element = current_element[key]
+
+    return current_element
+
+
+def set_nested_value(d: dict, value: Any, path: str) -> None:
+    """
+    Sets the value of a nested key in a dictionary based on the given path.
+
+    Args:
+        d: The dictionary to search.
+        value: The value to set.
+        path: A string representing the path to the nested key, separated by periods.
+
+    Raises:
+        ValueError: If an element in the path is not a dictionary.
+    """
+    keys = path.split(".")
+    last_element = keys[-1]
+    current_element = d
+
+    for i, key in enumerate(keys):
+        if key == last_element:
+            current_element[key] = value
+        elif key not in current_element:
+            raise KeyError(f"Key '{key}' not found at '{'.'.join(keys[:keys.index(key)])}'")
+        elif not isinstance(current_element[key], dict):
+            raise ValueError(f"Path '{'.'.join(keys[:i+1])}' does not lead to a dictionary.")
+        else:
+            current_element = current_element[key]
diff --git a/pyproject.toml b/pyproject.toml
index 519e67ee..014ae610 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,7 +33,6 @@ keywords = ["bumpversion", "version", "release"]
 dynamic = ["version"]
 dependencies = [
     "click",
-    "dotted-notation",
     "pydantic>=2.0.0",
     "pydantic-settings",
     "rich-click",
@@ -239,14 +238,6 @@ filename = "CHANGELOG.md"
 search = "{current_version}...HEAD"
 replace = "{current_version}...{new_version}"
 
-
-
-
-
-
-
-
-
 [tool.pydoclint]
 style = "google"
 exclude = '\.git|tests'


Test patch:
diff --git a/tests/fixtures/basic_cfg.toml b/tests/fixtures/basic_cfg.toml
index 7808ae95..ef598a13 100644
--- a/tests/fixtures/basic_cfg.toml
+++ b/tests/fixtures/basic_cfg.toml
@@ -44,3 +44,7 @@ values =[
     "dev",
     "gamma",
 ]
+
+[tool.othertool]
+bake_cookies = true
+ignore-words-list = "sugar, salt, flour"
diff --git a/tests/fixtures/partial_version_strings.toml b/tests/fixtures/partial_version_strings.toml
index 542f485a..cb09bc4d 100644
--- a/tests/fixtures/partial_version_strings.toml
+++ b/tests/fixtures/partial_version_strings.toml
@@ -26,3 +26,7 @@ build = [
 commit = false
 tag = false
 current_version = "0.0.2"
+
+[tool.othertool]
+bake_cookies = true
+ignore-words-list = "sugar, salt, flour"
diff --git a/tests/test_bump.py b/tests/test_bump.py
index 2e4d080a..7786dc45 100644
--- a/tests/test_bump.py
+++ b/tests/test_bump.py
@@ -199,19 +199,23 @@ def test_key_path_required_for_toml_change(tmp_path: Path, caplog):
     config_path.write_text(
         dedent(
             """
-        [project]
-        version = "0.1.26"
-
-        [tool.bumpversion]
-        current_version = "0.1.26"
-        allow_dirty = true
-        commit = true
-
-        [[tool.bumpversion.files]]
-        filename = "pyproject.toml"
-        search = "version = \\"{current_version}\\""
-        replace = "version = \\"{new_version}\\""
-        """
+            [project]
+            version = "0.1.26"
+
+            [tool.bumpversion]
+            current_version = "0.1.26"
+            allow_dirty = true
+            commit = true
+
+            [[tool.bumpversion.files]]
+            filename = "pyproject.toml"
+            search = "version = \\"{current_version}\\""
+            replace = "version = \\"{new_version}\\""
+
+            [tool.othertool]
+            bake_cookies = true
+            ignore-words-list = "sugar, salt, flour"
+            """
         )
     )
 
@@ -252,5 +256,9 @@ def test_key_path_required_for_toml_change(tmp_path: Path, caplog):
         filename = "pyproject.toml"
         search = "version = \\"{current_version}\\""
         replace = "version = \\"{new_version}\\""
+
+        [tool.othertool]
+        bake_cookies = true
+        ignore-words-list = "sugar, salt, flour"
         """
     )
diff --git a/tests/test_config/test_files.py b/tests/test_config/test_files.py
index d75fa66f..2b07fed9 100644
--- a/tests/test_config/test_files.py
+++ b/tests/test_config/test_files.py
@@ -5,7 +5,7 @@ from pathlib import Path
 
 from click.testing import CliRunner, Result
 import pytest
-from pytest import LogCaptureFixture, param
+from pytest import LogCaptureFixture, param, TempPathFactory
 
 from bumpversion.utils import get_context
 from bumpversion import config
@@ -70,11 +70,16 @@ class TestReadConfigFile:
     """Tests for reading the config file."""
 
     class TestWhenExplictConfigFileIsPassed:
-        def test_returns_empty_dict_when_missing_file(self, tmp_path: Path, caplog: LogCaptureFixture) -> None:
+        def test_returns_empty_dict_when_missing_file(
+            self, tmp_path_factory: TempPathFactory, caplog: LogCaptureFixture
+        ) -> None:
             """If an explicit config file is passed and doesn't exist, it returns an empty dict."""
+            caplog.set_level("INFO")
+            tmp_path = tmp_path_factory.mktemp("explicit-file-passed-")
             cfg_file = tmp_path / "bump.toml"
-            assert config.read_config_file(cfg_file) == {}
-            assert "Configuration file not found" in caplog.text
+            with inside_dir(tmp_path):
+                assert config.read_config_file(cfg_file) == {}
+                assert "Configuration file not found" in caplog.text
 
         def test_returns_dict_of_cfg_file(self, fixtures_path: Path) -> None:
             """Files with a .cfg suffix is parsed into a dict and returned."""
@@ -88,8 +93,12 @@ class TestReadConfigFile:
             expected = json.loads(fixtures_path.joinpath("basic_cfg_expected.json").read_text())
             assert config.read_config_file(cfg_file) == expected
 
-        def test_returns_empty_dict_with_unknown_suffix(self, tmp_path: Path, caplog: LogCaptureFixture) -> None:
+        def test_returns_empty_dict_with_unknown_suffix(
+            self, tmp_path_factory: TempPathFactory, caplog: LogCaptureFixture
+        ) -> None:
             """Files with an unknown suffix return an empty dict."""
+            caplog.set_level("INFO")
+            tmp_path = tmp_path_factory.mktemp("explicit-file-passed-")
             cfg_file = tmp_path / "basic_cfg.unknown"
             cfg_file.write_text('[tool.bumpversion]\ncurrent_version = "1.0.0"')
             with inside_dir(tmp_path):
@@ -101,6 +110,7 @@ class TestReadConfigFile:
 
         def test_returns_empty_dict(self, caplog: LogCaptureFixture) -> None:
             """If no explicit config file is passed, it returns an empty dict."""
+            caplog.set_level("INFO")
             assert config.read_config_file() == {}
             assert "No configuration file found." in caplog.text
 
@@ -275,7 +285,7 @@ def test_file_overrides_config(fixtures_path: Path):
     assert file_map["should_override_replace.txt"].regex == conf.regex
     assert file_map["should_override_replace.txt"].ignore_missing_version == conf.ignore_missing_version
 
-    assert file_map["should_override_parse.txt"].parse == "version(?P<major>\d+)"
+    assert file_map["should_override_parse.txt"].parse == r"version(?P<major>\d+)"
     assert file_map["should_override_parse.txt"].serialize == conf.serialize
     assert file_map["should_override_parse.txt"].search == conf.search
     assert file_map["should_override_parse.txt"].replace == conf.replace
diff --git a/tests/test_files.py b/tests/test_files.py
index 847d2cf7..78130ead 100644
--- a/tests/test_files.py
+++ b/tests/test_files.py
@@ -459,56 +459,87 @@ def test_bad_regex_search(tmp_path: Path, caplog) -> None:
     assert "Invalid regex" in caplog.text
 
 
-def test_datafileupdater_replaces_key(tmp_path: Path, fixtures_path: Path) -> None:
-    """A key specific key is replaced and nothing else is touched."""
-    # Arrange
-    config_path = tmp_path / "pyproject.toml"
-    fixture_path = fixtures_path / "partial_version_strings.toml"
-    shutil.copy(fixture_path, config_path)
+class TestDataFileUpdater:
+    """Tests for the DataFileUpdater class."""
 
-    contents_before = config_path.read_text()
-    conf = config.get_configuration(config_file=config_path, files=[{"filename": str(config_path)}])
-    version_config = VersionConfig(conf.parse, conf.serialize, conf.search, conf.replace, conf.parts)
-    current_version = version_config.parse(conf.current_version)
-    new_version = current_version.bump("minor", version_config.order)
-    datafile_config = FileChange(
-        filename=str(config_path),
-        key_path="tool.bumpversion.current_version",
-        search=conf.search,
-        replace=conf.replace,
-        regex=conf.regex,
-        ignore_missing_version=conf.ignore_missing_version,
-        serialize=conf.serialize,
-        parse=conf.parse,
-    )
+    def test_update_file_does_not_modify_non_toml_files(self, tmp_path: Path) -> None:
+        """A non-TOML file is not modified."""
+        # Arrange
+        version_path = tmp_path / "VERSION"
+        version_path.write_text("1.2.3")
 
-    # Act
-    files.DataFileUpdater(datafile_config, version_config.part_configs).update_file(
-        current_version, new_version, get_context(conf)
-    )
+        overrides = {"current_version": "1.2.3", "files": [{"filename": str(version_path)}]}
+        conf, version_config, current_version = get_config_data(overrides)
+        new_version = current_version.bump("patch", version_config.order)
+        datafile_config = FileChange(
+            filename=str(version_path),
+            key_path="",
+            search=conf.search,
+            replace=conf.replace,
+            regex=conf.regex,
+            ignore_missing_version=conf.ignore_missing_version,
+            serialize=conf.serialize,
+            parse=conf.parse,
+        )
 
-    # Assert
-    contents_after = config_path.read_text()
-    toml_data = tomlkit.parse(config_path.read_text()).unwrap()
-    actual_difference = list(
-        context_diff(
-            contents_before.splitlines(),
-            contents_after.splitlines(),
-            fromfile="before",
-            tofile="after",
-            n=0,
-            lineterm="",
+        # Act
+        files.DataFileUpdater(datafile_config, version_config.part_configs).update_file(
+            current_version, new_version, get_context(conf)
         )
-    )
-    expected_difference = [
-        "*** before",
-        "--- after",
-        "***************",
-        "*** 28 ****",
-        '! current_version = "0.0.2"',
-        "--- 28 ----",
-        '! current_version = "0.1.0"',
-    ]
-    assert actual_difference == expected_difference
-    assert toml_data["tool"]["pdm"]["dev-dependencies"]["lint"] == ["ruff==0.0.292"]
-    assert toml_data["tool"]["bumpversion"]["current_version"] == "0.1.0"
+
+        # Assert
+        assert version_path.read_text() == "1.2.3"
+
+    def test_update_replaces_key(self, tmp_path: Path, fixtures_path: Path) -> None:
+        """A key specific key is replaced and nothing else is touched."""
+        # Arrange
+        config_path = tmp_path / "pyproject.toml"
+        fixture_path = fixtures_path / "partial_version_strings.toml"
+        shutil.copy(fixture_path, config_path)
+
+        contents_before = config_path.read_text()
+        conf = config.get_configuration(config_file=config_path, files=[{"filename": str(config_path)}])
+        version_config = VersionConfig(conf.parse, conf.serialize, conf.search, conf.replace, conf.parts)
+        current_version = version_config.parse(conf.current_version)
+        new_version = current_version.bump("minor", version_config.order)
+        datafile_config = FileChange(
+            filename=str(config_path),
+            key_path="tool.bumpversion.current_version",
+            search=conf.search,
+            replace=conf.replace,
+            regex=conf.regex,
+            ignore_missing_version=conf.ignore_missing_version,
+            serialize=conf.serialize,
+            parse=conf.parse,
+        )
+
+        # Act
+        files.DataFileUpdater(datafile_config, version_config.part_configs).update_file(
+            current_version, new_version, get_context(conf)
+        )
+
+        # Assert
+        contents_after = config_path.read_text()
+        toml_data = tomlkit.parse(config_path.read_text()).unwrap()
+        actual_difference = list(
+            context_diff(
+                contents_before.splitlines(),
+                contents_after.splitlines(),
+                fromfile="before",
+                tofile="after",
+                n=0,
+                lineterm="",
+            )
+        )
+        expected_difference = [
+            "*** before",
+            "--- after",
+            "***************",
+            "*** 28 ****",
+            '! current_version = "0.0.2"',
+            "--- 28 ----",
+            '! current_version = "0.1.0"',
+        ]
+        assert actual_difference == expected_difference
+        assert toml_data["tool"]["pdm"]["dev-dependencies"]["lint"] == ["ruff==0.0.292"]
+        assert toml_data["tool"]["bumpversion"]["current_version"] == "0.1.0"
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 00000000..b0ffe63d
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,80 @@
+"""Tests for the utils module."""
+import pytest
+from bumpversion import utils
+
+
+class TestGetNestedValue:
+    """Test the get_nested_value function."""
+
+    def test_returns_value(self):
+        """It should return the value of the nested key."""
+        # Arrange
+        d = {"a": {"b": {"c": 1}}}
+        path = "a.b.c"
+        expected_value = 1
+
+        # Act
+        actual_value = utils.get_nested_value(d, path)
+
+        # Assert
+        assert actual_value == expected_value
+
+    def test_invalid_path_raises_keyerror(self):
+        """It should raise a KeyError if the path is invalid."""
+        # Arrange
+        d = {"a": {"b": {"c": 1}}}
+        path = "a.b.d"
+
+        # Act/Assert
+        with pytest.raises(KeyError):
+            utils.get_nested_value(d, path)
+
+    def test_non_dict_element_raises_valueerror(self):
+        """It should raise a ValueError if an element in the path is not a dict."""
+        # Arrange
+        d = {"a": {"b": {"c": 1}}}
+        path = "a.b.c.d"
+
+        # Act/Assert
+        with pytest.raises(ValueError):
+            utils.get_nested_value(d, path)
+
+
+class TestSetNestedValue:
+    """Test the set_nested_value function."""
+
+    def test_sets_value(self):
+        """It should set the value of the nested key."""
+        # Arrange
+        d = {"a": {"b": {"c": 1}}}
+        path = "a.b.c"
+        value = 2
+        expected_d = {"a": {"b": {"c": 2}}}
+
+        # Act
+        utils.set_nested_value(d, value, path)
+
+        # Assert
+        assert d == expected_d
+
+    def test_invalid_path_raises_keyerror(self):
+        """It should raise a KeyError if the path is invalid."""
+        # Arrange
+        d = {"a": {"b": {"c": 1}}}
+        path = "a.b.d.e"
+        value = 2
+
+        # Act/Assert
+        with pytest.raises(KeyError):
+            utils.set_nested_value(d, value, path)
+
+    def test_non_dict_element_raises_valueerror(self):
+        """It should raise a ValueError if an element in the path is not a dict."""
+        # Arrange
+        d = {"a": {"b": {"c": 1}}}
+        path = "a.b.c.d"
+        value = 2
+
+        # Act/Assert
+        with pytest.raises(ValueError):
+            utils.set_nested_value(d, value, path)