Skip to content

Commit 839f17f

Browse files
committed
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.
1 parent f122abd commit 839f17f

9 files changed

+267
-80
lines changed

bumpversion/files.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from pathlib import Path
77
from typing import Dict, List, MutableMapping, Optional
88

9+
from utils import get_nested_value, set_nested_value
10+
911
from bumpversion.config.models import FileChange, VersionPartConfig
1012
from bumpversion.exceptions import VersionNotFoundError
1113
from bumpversion.ui import get_indented_logger
@@ -327,11 +329,10 @@ def _update_toml_file(
327329
self, search_for: re.Pattern, raw_search_pattern: str, replace_with: str, dry_run: bool = False
328330
) -> None:
329331
"""Update a TOML file."""
330-
import dotted
331332
import tomlkit
332333

333334
toml_data = tomlkit.parse(self.path.read_text())
334-
value_before = dotted.get(toml_data, self.file_change.key_path)
335+
value_before = get_nested_value(toml_data, self.file_change.key_path)
335336

336337
if value_before is None:
337338
raise KeyError(f"Key path '{self.file_change.key_path}' does not exist in {self.path}")
@@ -347,5 +348,6 @@ def _update_toml_file(
347348
if dry_run:
348349
return
349350

350-
dotted.update(toml_data, self.file_change.key_path, new_value)
351+
set_nested_value(toml_data, new_value, self.file_change.key_path)
352+
351353
self.path.write_text(tomlkit.dumps(toml_data))

bumpversion/utils.py

+57
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,60 @@ def get_context(
7474
def get_overrides(**kwargs) -> dict:
7575
"""Return a dictionary containing only the overridden key-values."""
7676
return {key: val for key, val in kwargs.items() if val is not None}
77+
78+
79+
def get_nested_value(d: dict, path: str) -> Any:
80+
"""
81+
Retrieves the value of a nested key in a dictionary based on the given path.
82+
83+
Args:
84+
d: The dictionary to search.
85+
path: A string representing the path to the nested key, separated by periods.
86+
87+
Returns:
88+
The value of the nested key.
89+
90+
Raises:
91+
KeyError: If a key in the path does not exist.
92+
ValueError: If an element in the path is not a dictionary.
93+
"""
94+
keys = path.split(".")
95+
current_element = d
96+
97+
for key in keys:
98+
if not isinstance(current_element, dict):
99+
raise ValueError(f"Element at '{'.'.join(keys[:keys.index(key)])}' is not a dictionary")
100+
101+
if key not in current_element:
102+
raise KeyError(f"Key '{key}' not found at '{'.'.join(keys[:keys.index(key)])}'")
103+
104+
current_element = current_element[key]
105+
106+
return current_element
107+
108+
109+
def set_nested_value(d: dict, value: Any, path: str) -> None:
110+
"""
111+
Sets the value of a nested key in a dictionary based on the given path.
112+
113+
Args:
114+
d: The dictionary to search.
115+
value: The value to set.
116+
path: A string representing the path to the nested key, separated by periods.
117+
118+
Raises:
119+
ValueError: If an element in the path is not a dictionary.
120+
"""
121+
keys = path.split(".")
122+
last_element = keys[-1]
123+
current_element = d
124+
125+
for i, key in enumerate(keys):
126+
if key == last_element:
127+
current_element[key] = value
128+
elif key not in current_element:
129+
raise KeyError(f"Key '{key}' not found at '{'.'.join(keys[:keys.index(key)])}'")
130+
elif not isinstance(current_element[key], dict):
131+
raise ValueError(f"Path '{'.'.join(keys[:i+1])}' does not lead to a dictionary.")
132+
else:
133+
current_element = current_element[key]

pyproject.toml

-9
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ keywords = ["bumpversion", "version", "release"]
3333
dynamic = ["version"]
3434
dependencies = [
3535
"click",
36-
"dotted-notation",
3736
"pydantic>=2.0.0",
3837
"pydantic-settings",
3938
"rich-click",
@@ -239,14 +238,6 @@ filename = "CHANGELOG.md"
239238
search = "{current_version}...HEAD"
240239
replace = "{current_version}...{new_version}"
241240

242-
243-
244-
245-
246-
247-
248-
249-
250241
[tool.pydoclint]
251242
style = "google"
252243
exclude = '\.git|tests'

tests/fixtures/basic_cfg.toml

+4
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,7 @@ values =[
4444
"dev",
4545
"gamma",
4646
]
47+
48+
[tool.othertool]
49+
bake_cookies = true
50+
ignore-words-list = "sugar, salt, flour"

tests/fixtures/partial_version_strings.toml

+4
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,7 @@ build = [
2626
commit = false
2727
tag = false
2828
current_version = "0.0.2"
29+
30+
[tool.othertool]
31+
bake_cookies = true
32+
ignore-words-list = "sugar, salt, flour"

tests/test_bump.py

+21-13
Original file line numberDiff line numberDiff line change
@@ -199,19 +199,23 @@ def test_key_path_required_for_toml_change(tmp_path: Path, caplog):
199199
config_path.write_text(
200200
dedent(
201201
"""
202-
[project]
203-
version = "0.1.26"
204-
205-
[tool.bumpversion]
206-
current_version = "0.1.26"
207-
allow_dirty = true
208-
commit = true
209-
210-
[[tool.bumpversion.files]]
211-
filename = "pyproject.toml"
212-
search = "version = \\"{current_version}\\""
213-
replace = "version = \\"{new_version}\\""
214-
"""
202+
[project]
203+
version = "0.1.26"
204+
205+
[tool.bumpversion]
206+
current_version = "0.1.26"
207+
allow_dirty = true
208+
commit = true
209+
210+
[[tool.bumpversion.files]]
211+
filename = "pyproject.toml"
212+
search = "version = \\"{current_version}\\""
213+
replace = "version = \\"{new_version}\\""
214+
215+
[tool.othertool]
216+
bake_cookies = true
217+
ignore-words-list = "sugar, salt, flour"
218+
"""
215219
)
216220
)
217221

@@ -252,5 +256,9 @@ def test_key_path_required_for_toml_change(tmp_path: Path, caplog):
252256
filename = "pyproject.toml"
253257
search = "version = \\"{current_version}\\""
254258
replace = "version = \\"{new_version}\\""
259+
260+
[tool.othertool]
261+
bake_cookies = true
262+
ignore-words-list = "sugar, salt, flour"
255263
"""
256264
)

tests/test_config/test_files.py

+16-6
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from click.testing import CliRunner, Result
77
import pytest
8-
from pytest import LogCaptureFixture, param
8+
from pytest import LogCaptureFixture, param, TempPathFactory
99

1010
from bumpversion.utils import get_context
1111
from bumpversion import config
@@ -70,11 +70,16 @@ class TestReadConfigFile:
7070
"""Tests for reading the config file."""
7171

7272
class TestWhenExplictConfigFileIsPassed:
73-
def test_returns_empty_dict_when_missing_file(self, tmp_path: Path, caplog: LogCaptureFixture) -> None:
73+
def test_returns_empty_dict_when_missing_file(
74+
self, tmp_path_factory: TempPathFactory, caplog: LogCaptureFixture
75+
) -> None:
7476
"""If an explicit config file is passed and doesn't exist, it returns an empty dict."""
77+
caplog.set_level("INFO")
78+
tmp_path = tmp_path_factory.mktemp("explicit-file-passed-")
7579
cfg_file = tmp_path / "bump.toml"
76-
assert config.read_config_file(cfg_file) == {}
77-
assert "Configuration file not found" in caplog.text
80+
with inside_dir(tmp_path):
81+
assert config.read_config_file(cfg_file) == {}
82+
assert "Configuration file not found" in caplog.text
7883

7984
def test_returns_dict_of_cfg_file(self, fixtures_path: Path) -> None:
8085
"""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:
8893
expected = json.loads(fixtures_path.joinpath("basic_cfg_expected.json").read_text())
8994
assert config.read_config_file(cfg_file) == expected
9095

91-
def test_returns_empty_dict_with_unknown_suffix(self, tmp_path: Path, caplog: LogCaptureFixture) -> None:
96+
def test_returns_empty_dict_with_unknown_suffix(
97+
self, tmp_path_factory: TempPathFactory, caplog: LogCaptureFixture
98+
) -> None:
9299
"""Files with an unknown suffix return an empty dict."""
100+
caplog.set_level("INFO")
101+
tmp_path = tmp_path_factory.mktemp("explicit-file-passed-")
93102
cfg_file = tmp_path / "basic_cfg.unknown"
94103
cfg_file.write_text('[tool.bumpversion]\ncurrent_version = "1.0.0"')
95104
with inside_dir(tmp_path):
@@ -101,6 +110,7 @@ class TestWhenNoConfigFileIsPassed:
101110

102111
def test_returns_empty_dict(self, caplog: LogCaptureFixture) -> None:
103112
"""If no explicit config file is passed, it returns an empty dict."""
113+
caplog.set_level("INFO")
104114
assert config.read_config_file() == {}
105115
assert "No configuration file found." in caplog.text
106116

@@ -275,7 +285,7 @@ def test_file_overrides_config(fixtures_path: Path):
275285
assert file_map["should_override_replace.txt"].regex == conf.regex
276286
assert file_map["should_override_replace.txt"].ignore_missing_version == conf.ignore_missing_version
277287

278-
assert file_map["should_override_parse.txt"].parse == "version(?P<major>\d+)"
288+
assert file_map["should_override_parse.txt"].parse == r"version(?P<major>\d+)"
279289
assert file_map["should_override_parse.txt"].serialize == conf.serialize
280290
assert file_map["should_override_parse.txt"].search == conf.search
281291
assert file_map["should_override_parse.txt"].replace == conf.replace

tests/test_files.py

+80-49
Original file line numberDiff line numberDiff line change
@@ -459,56 +459,87 @@ def test_bad_regex_search(tmp_path: Path, caplog) -> None:
459459
assert "Invalid regex" in caplog.text
460460

461461

462-
def test_datafileupdater_replaces_key(tmp_path: Path, fixtures_path: Path) -> None:
463-
"""A key specific key is replaced and nothing else is touched."""
464-
# Arrange
465-
config_path = tmp_path / "pyproject.toml"
466-
fixture_path = fixtures_path / "partial_version_strings.toml"
467-
shutil.copy(fixture_path, config_path)
462+
class TestDataFileUpdater:
463+
"""Tests for the DataFileUpdater class."""
468464

469-
contents_before = config_path.read_text()
470-
conf = config.get_configuration(config_file=config_path, files=[{"filename": str(config_path)}])
471-
version_config = VersionConfig(conf.parse, conf.serialize, conf.search, conf.replace, conf.parts)
472-
current_version = version_config.parse(conf.current_version)
473-
new_version = current_version.bump("minor", version_config.order)
474-
datafile_config = FileChange(
475-
filename=str(config_path),
476-
key_path="tool.bumpversion.current_version",
477-
search=conf.search,
478-
replace=conf.replace,
479-
regex=conf.regex,
480-
ignore_missing_version=conf.ignore_missing_version,
481-
serialize=conf.serialize,
482-
parse=conf.parse,
483-
)
465+
def test_update_file_does_not_modify_non_toml_files(self, tmp_path: Path) -> None:
466+
"""A non-TOML file is not modified."""
467+
# Arrange
468+
version_path = tmp_path / "VERSION"
469+
version_path.write_text("1.2.3")
484470

485-
# Act
486-
files.DataFileUpdater(datafile_config, version_config.part_configs).update_file(
487-
current_version, new_version, get_context(conf)
488-
)
471+
overrides = {"current_version": "1.2.3", "files": [{"filename": str(version_path)}]}
472+
conf, version_config, current_version = get_config_data(overrides)
473+
new_version = current_version.bump("patch", version_config.order)
474+
datafile_config = FileChange(
475+
filename=str(version_path),
476+
key_path="",
477+
search=conf.search,
478+
replace=conf.replace,
479+
regex=conf.regex,
480+
ignore_missing_version=conf.ignore_missing_version,
481+
serialize=conf.serialize,
482+
parse=conf.parse,
483+
)
489484

490-
# Assert
491-
contents_after = config_path.read_text()
492-
toml_data = tomlkit.parse(config_path.read_text()).unwrap()
493-
actual_difference = list(
494-
context_diff(
495-
contents_before.splitlines(),
496-
contents_after.splitlines(),
497-
fromfile="before",
498-
tofile="after",
499-
n=0,
500-
lineterm="",
485+
# Act
486+
files.DataFileUpdater(datafile_config, version_config.part_configs).update_file(
487+
current_version, new_version, get_context(conf)
501488
)
502-
)
503-
expected_difference = [
504-
"*** before",
505-
"--- after",
506-
"***************",
507-
"*** 28 ****",
508-
'! current_version = "0.0.2"',
509-
"--- 28 ----",
510-
'! current_version = "0.1.0"',
511-
]
512-
assert actual_difference == expected_difference
513-
assert toml_data["tool"]["pdm"]["dev-dependencies"]["lint"] == ["ruff==0.0.292"]
514-
assert toml_data["tool"]["bumpversion"]["current_version"] == "0.1.0"
489+
490+
# Assert
491+
assert version_path.read_text() == "1.2.3"
492+
493+
def test_update_replaces_key(self, tmp_path: Path, fixtures_path: Path) -> None:
494+
"""A key specific key is replaced and nothing else is touched."""
495+
# Arrange
496+
config_path = tmp_path / "pyproject.toml"
497+
fixture_path = fixtures_path / "partial_version_strings.toml"
498+
shutil.copy(fixture_path, config_path)
499+
500+
contents_before = config_path.read_text()
501+
conf = config.get_configuration(config_file=config_path, files=[{"filename": str(config_path)}])
502+
version_config = VersionConfig(conf.parse, conf.serialize, conf.search, conf.replace, conf.parts)
503+
current_version = version_config.parse(conf.current_version)
504+
new_version = current_version.bump("minor", version_config.order)
505+
datafile_config = FileChange(
506+
filename=str(config_path),
507+
key_path="tool.bumpversion.current_version",
508+
search=conf.search,
509+
replace=conf.replace,
510+
regex=conf.regex,
511+
ignore_missing_version=conf.ignore_missing_version,
512+
serialize=conf.serialize,
513+
parse=conf.parse,
514+
)
515+
516+
# Act
517+
files.DataFileUpdater(datafile_config, version_config.part_configs).update_file(
518+
current_version, new_version, get_context(conf)
519+
)
520+
521+
# Assert
522+
contents_after = config_path.read_text()
523+
toml_data = tomlkit.parse(config_path.read_text()).unwrap()
524+
actual_difference = list(
525+
context_diff(
526+
contents_before.splitlines(),
527+
contents_after.splitlines(),
528+
fromfile="before",
529+
tofile="after",
530+
n=0,
531+
lineterm="",
532+
)
533+
)
534+
expected_difference = [
535+
"*** before",
536+
"--- after",
537+
"***************",
538+
"*** 28 ****",
539+
'! current_version = "0.0.2"',
540+
"--- 28 ----",
541+
'! current_version = "0.1.0"',
542+
]
543+
assert actual_difference == expected_difference
544+
assert toml_data["tool"]["pdm"]["dev-dependencies"]["lint"] == ["ruff==0.0.292"]
545+
assert toml_data["tool"]["bumpversion"]["current_version"] == "0.1.0"

0 commit comments

Comments
 (0)