Skip to content

Commit

Permalink
Fixed extra whitespace added when updating pyproject.toml
Browse files Browse the repository at this point in the history
- 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.
  • Loading branch information
coordt committed Jan 13, 2024
1 parent f122abd commit 839f17f
Show file tree
Hide file tree
Showing 9 changed files with 267 additions and 80 deletions.
8 changes: 5 additions & 3 deletions bumpversion/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand All @@ -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))
57 changes: 57 additions & 0 deletions bumpversion/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
9 changes: 0 additions & 9 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ keywords = ["bumpversion", "version", "release"]
dynamic = ["version"]
dependencies = [
"click",
"dotted-notation",
"pydantic>=2.0.0",
"pydantic-settings",
"rich-click",
Expand Down Expand Up @@ -239,14 +238,6 @@ filename = "CHANGELOG.md"
search = "{current_version}...HEAD"
replace = "{current_version}...{new_version}"









[tool.pydoclint]
style = "google"
exclude = '\.git|tests'
Expand Down
4 changes: 4 additions & 0 deletions tests/fixtures/basic_cfg.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,7 @@ values =[
"dev",
"gamma",
]

[tool.othertool]
bake_cookies = true
ignore-words-list = "sugar, salt, flour"
4 changes: 4 additions & 0 deletions tests/fixtures/partial_version_strings.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
34 changes: 21 additions & 13 deletions tests/test_bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
"""
)
)

Expand Down Expand Up @@ -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"
"""
)
22 changes: 16 additions & 6 deletions tests/test_config/test_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand All @@ -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):
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
129 changes: 80 additions & 49 deletions tests/test_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading

0 comments on commit 839f17f

Please sign in to comment.