diff --git a/bumpversion/files.py b/bumpversion/files.py index aee9c505..fc405dc4 100644 --- a/bumpversion/files.py +++ b/bumpversion/files.py @@ -6,6 +6,8 @@ from pathlib import Path from typing import Dict, List, MutableMapping, Optional +from utils import get_nested_value, set_nested_value + from bumpversion.config.models import FileChange, VersionPartConfig from bumpversion.exceptions import VersionNotFoundError from bumpversion.ui import get_indented_logger @@ -327,11 +329,10 @@ def _update_toml_file( 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 +348,6 @@ def _update_toml_file( 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' 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 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 @@ def test_returns_dict_of_toml_file(self, fixtures_path: Path) -> None: 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 TestWhenNoConfigFileIsPassed: 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\d+)" + assert file_map["should_override_parse.txt"].parse == r"version(?P\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)