Skip to content

Commit e407974

Browse files
committed
Refactored configuration file updating.
TOML files are parsed, specific values are updated, and re-written to avoid updating the wrong data. It uses a two-way parser, so all formatting and comments are maintained. INI-type configuration files use the old way, since that format is deprecated.
1 parent fc7b961 commit e407974

9 files changed

+341
-89
lines changed

bumpversion/bump.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
from bumpversion.files import ConfiguredFile
99
from bumpversion.version_part import Version
1010

11-
from bumpversion.config import Config, update_config_file
11+
from bumpversion.config import Config
12+
from bumpversion.config.files import update_config_file, update_ini_config_file
1213
from bumpversion.exceptions import ConfigurationError
1314
from bumpversion.utils import get_context, key_val_string
1415

@@ -81,7 +82,10 @@ def do_bump(
8182

8283
configured_files = resolve_file_config(config.files_to_modify, config.version_config)
8384
modify_files(configured_files, version, next_version, ctx, dry_run)
84-
update_config_file(config_file, config.current_version, next_version_str, dry_run)
85+
if config_file and config_file.suffix in {".cfg", ".ini"}:
86+
update_ini_config_file(config_file, config.current_version, next_version_str, dry_run)
87+
else:
88+
update_config_file(config_file, config, version, next_version, ctx, dry_run)
8589

8690
ctx = get_context(config, version, next_version)
8791
ctx["new_version"] = next_version_str

bumpversion/config/__init__.py

+4-72
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
from __future__ import annotations
33

44
import logging
5-
import re
6-
from difflib import context_diff
7-
from pathlib import Path
8-
from typing import Union
5+
from typing import TYPE_CHECKING, Union
96

107
from bumpversion.config.files import read_config_file
118
from bumpversion.config.models import Config
129
from bumpversion.exceptions import ConfigurationError
1310

11+
if TYPE_CHECKING: # pragma: no-coverage
12+
from pathlib import Path
13+
1414
logger = logging.getLogger(__name__)
1515

1616
DEFAULTS = {
@@ -110,71 +110,3 @@ def check_current_version(config: Config) -> str:
110110
return current_version
111111

112112
raise ConfigurationError("Unable to determine the current version.")
113-
114-
115-
def update_config_file(
116-
config_file: Union[str, Path, None], current_version: str, new_version: str, dry_run: bool = False
117-
) -> None:
118-
"""
119-
Update the current_version key in the configuration file.
120-
121-
If no explicit configuration file is passed, it will search in several files to
122-
find its configuration.
123-
124-
Instead of parsing and re-writing the config file with new information, it will use
125-
a regular expression to just replace the current_version value. The idea is it will
126-
avoid unintentional changes (like formatting) to the config file.
127-
128-
Args:
129-
config_file: The configuration file to explicitly use.
130-
current_version: The serialized current version.
131-
new_version: The serialized new version.
132-
dry_run: True if the update should be a dry run.
133-
"""
134-
toml_current_version_regex = re.compile(
135-
f'(?P<section_prefix>\\[tool\\.bumpversion]\n[^[]*current_version\\s*=\\s*)(\\"{current_version}\\")',
136-
re.MULTILINE,
137-
)
138-
cfg_current_version_regex = re.compile(
139-
f"(?P<section_prefix>\\[bumpversion]\n[^[]*current_version\\s*=\\s*)(?P<version>{current_version})",
140-
re.MULTILINE,
141-
)
142-
143-
if not config_file:
144-
logger.info("No configuration file found to update.")
145-
return
146-
147-
config_path = Path(config_file)
148-
existing_config = config_path.read_text()
149-
if config_path.suffix == ".cfg" and cfg_current_version_regex.search(existing_config):
150-
sub_str = f"\\g<section_prefix>{new_version}"
151-
new_config = cfg_current_version_regex.sub(sub_str, existing_config)
152-
elif config_path.suffix == ".toml" and toml_current_version_regex.search(existing_config):
153-
sub_str = f'\\g<section_prefix>"{new_version}"'
154-
new_config = toml_current_version_regex.sub(sub_str, existing_config)
155-
else:
156-
logger.info("Could not find the current version in the config file: %s.", config_path)
157-
return
158-
159-
logger.info(
160-
"%s to config file %s:",
161-
"Would write" if dry_run else "Writing",
162-
config_path,
163-
)
164-
165-
logger.info(
166-
"\n".join(
167-
list(
168-
context_diff(
169-
existing_config.splitlines(),
170-
new_config.splitlines(),
171-
fromfile=f"before {config_path}",
172-
tofile=f"after {config_path}",
173-
lineterm="",
174-
)
175-
)
176-
)
177-
)
178-
179-
if not dry_run:
180-
config_path.write_text(new_config)

bumpversion/config/files.py

+108-1
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@
33
from __future__ import annotations
44

55
import logging
6+
import re
7+
from difflib import context_diff
68
from pathlib import Path
7-
from typing import Any, Dict, Union
9+
from typing import TYPE_CHECKING, Any, Dict, MutableMapping, Union
810

911
from bumpversion.ui import print_warning
1012

13+
if TYPE_CHECKING: # pragma: no-coverage
14+
from bumpversion.config.models import Config
15+
from bumpversion.version_part import Version
16+
1117
logger = logging.getLogger(__name__)
1218

1319
CONFIG_FILE_SEARCH_ORDER = (
@@ -144,3 +150,104 @@ def read_toml_file(file_path: Path) -> Dict[str, Any]:
144150
toml_data = tomlkit.parse(file_path.read_text()).unwrap()
145151

146152
return toml_data.get("tool", {}).get("bumpversion", {})
153+
154+
155+
def update_config_file(
156+
config_file: Union[str, Path],
157+
config: Config,
158+
current_version: Version,
159+
new_version: Version,
160+
context: MutableMapping,
161+
dry_run: bool = False,
162+
) -> None:
163+
"""
164+
Update the current_version key in the configuration file.
165+
166+
Args:
167+
config_file: The configuration file to explicitly use.
168+
config: The configuration to use.
169+
current_version: The current version.
170+
new_version: The new version.
171+
context: The context to use for serialization.
172+
dry_run: True if the update should be a dry run.
173+
"""
174+
from bumpversion.config.models import FileConfig
175+
from bumpversion.files import DataFileUpdater
176+
177+
if not config_file:
178+
logger.info("No configuration file found to update.")
179+
return
180+
181+
config_path = Path(config_file)
182+
if config_path.suffix != ".toml":
183+
logger.info("Could not find the current version in the config file: %s.", config_path)
184+
return
185+
186+
# TODO: Eventually this should be transformed into another default "files_to_modify" entry
187+
datafile_config = FileConfig(
188+
filename=str(config_path),
189+
key_path="tool.bumpversion.current_version",
190+
search=config.search,
191+
replace=config.replace,
192+
regex=config.regex,
193+
ignore_missing_version=config.ignore_missing_version,
194+
serialize=config.serialize,
195+
parse=config.parse,
196+
)
197+
198+
updater = DataFileUpdater(datafile_config, config.version_config.part_configs)
199+
updater.update_file(current_version, new_version, context, dry_run)
200+
201+
202+
def update_ini_config_file(
203+
config_file: Union[str, Path], current_version: str, new_version: str, dry_run: bool = False
204+
) -> None:
205+
"""
206+
Update the current_version key in the configuration file.
207+
208+
Instead of parsing and re-writing the config file with new information, it will use
209+
a regular expression to just replace the current_version value. The idea is it will
210+
avoid unintentional changes (like formatting) to the config file.
211+
212+
Args:
213+
config_file: The configuration file to explicitly use.
214+
current_version: The serialized current version.
215+
new_version: The serialized new version.
216+
dry_run: True if the update should be a dry run.
217+
"""
218+
cfg_current_version_regex = re.compile(
219+
f"(?P<section_prefix>\\[bumpversion]\n[^[]*current_version\\s*=\\s*)(?P<version>{current_version})",
220+
re.MULTILINE,
221+
)
222+
223+
config_path = Path(config_file)
224+
existing_config = config_path.read_text()
225+
if config_path.suffix == ".cfg" and cfg_current_version_regex.search(existing_config):
226+
sub_str = f"\\g<section_prefix>{new_version}"
227+
new_config = cfg_current_version_regex.sub(sub_str, existing_config)
228+
else:
229+
logger.info("Could not find the current version in the config file: %s.", config_path)
230+
return
231+
232+
logger.info(
233+
"%s to config file %s:",
234+
"Would write" if dry_run else "Writing",
235+
config_path,
236+
)
237+
238+
logger.info(
239+
"\n".join(
240+
list(
241+
context_diff(
242+
existing_config.splitlines(),
243+
new_config.splitlines(),
244+
fromfile=f"before {config_path}",
245+
tofile=f"after {config_path}",
246+
lineterm="",
247+
)
248+
)
249+
)
250+
)
251+
252+
if not dry_run:
253+
config_path.write_text(new_config)

bumpversion/files.py

+90-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import re
44
from copy import deepcopy
55
from difflib import context_diff
6-
from typing import List, MutableMapping, Optional, Tuple
6+
from pathlib import Path
7+
from typing import Dict, List, MutableMapping, Optional, Tuple
78

89
from bumpversion.config.models import FileConfig, VersionPartConfig
910
from bumpversion.exceptions import VersionNotFoundError
@@ -248,3 +249,91 @@ def _check_files_contain_version(
248249
for f in files:
249250
context["current_version"] = f.version_config.serialize(current_version, context)
250251
f.contains_version(current_version, context)
252+
253+
254+
class FileUpdater:
255+
"""A class to handle updating files."""
256+
257+
def __init__(
258+
self,
259+
file_cfg: FileConfig,
260+
version_config: VersionConfig,
261+
search: Optional[str] = None,
262+
replace: Optional[str] = None,
263+
) -> None:
264+
self.path = file_cfg.filename
265+
self.version_config = version_config
266+
self.parse = file_cfg.parse or version_config.parse_regex.pattern
267+
self.serialize = file_cfg.serialize or version_config.serialize_formats
268+
self.search = search or file_cfg.search or version_config.search
269+
self.replace = replace or file_cfg.replace or version_config.replace
270+
self.regex = file_cfg.regex or False
271+
self.ignore_missing_version = file_cfg.ignore_missing_version or False
272+
self.version_config = VersionConfig(
273+
self.parse, self.serialize, self.search, self.replace, version_config.part_configs
274+
)
275+
self._newlines: Optional[str] = None
276+
277+
def update_file(
278+
self, current_version: Version, new_version: Version, context: MutableMapping, dry_run: bool = False
279+
) -> None:
280+
"""Update the files."""
281+
# TODO: Implement this
282+
pass
283+
284+
285+
class DataFileUpdater:
286+
"""A class to handle updating files."""
287+
288+
def __init__(
289+
self,
290+
file_cfg: FileConfig,
291+
version_part_configs: Dict[str, VersionPartConfig],
292+
) -> None:
293+
self.path = Path(file_cfg.filename)
294+
self.key_path = file_cfg.key_path
295+
self.search = file_cfg.search
296+
self.replace = file_cfg.replace
297+
self.regex = file_cfg.regex
298+
self.ignore_missing_version = file_cfg.ignore_missing_version
299+
self.version_config = VersionConfig(
300+
file_cfg.parse, file_cfg.serialize, file_cfg.search, file_cfg.replace, version_part_configs
301+
)
302+
303+
def update_file(
304+
self, current_version: Version, new_version: Version, context: MutableMapping, dry_run: bool = False
305+
) -> None:
306+
"""Update the files."""
307+
new_context = deepcopy(context)
308+
new_context["current_version"] = self.version_config.serialize(current_version, context)
309+
new_context["new_version"] = self.version_config.serialize(new_version, context)
310+
search_for, raw_search_pattern = get_search_pattern(self.search, new_context, self.regex)
311+
replace_with = self.replace.format(**new_context)
312+
if self.path.suffix == ".toml":
313+
self._update_toml_file(search_for, raw_search_pattern, replace_with, dry_run)
314+
315+
def _update_toml_file(
316+
self, search_for: re.Pattern, raw_search_pattern: str, replace_with: str, dry_run: bool = False
317+
) -> None:
318+
"""Update a TOML file."""
319+
import dotted
320+
import tomlkit
321+
322+
toml_data = tomlkit.parse(self.path.read_text())
323+
value_before = dotted.get(toml_data, self.key_path)
324+
325+
if value_before is None:
326+
raise KeyError(f"Key path '{self.key_path}' does not exist in {self.path}")
327+
elif not contains_pattern(search_for, value_before) and not self.ignore_missing_version:
328+
raise ValueError(
329+
f"Key '{self.key_path}' in {self.path} does not contain the correct contents: {raw_search_pattern}"
330+
)
331+
332+
new_value = search_for.sub(replace_with, value_before)
333+
log_changes(f"{self.path}:{self.key_path}", value_before, new_value, dry_run)
334+
335+
if dry_run:
336+
return
337+
338+
dotted.update(toml_data, self.key_path, new_value)
339+
self.path.write_text(tomlkit.dumps(toml_data))

pyproject.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ keywords = ["bumpversion", "version", "release"]
3333
dynamic = ["version"]
3434
dependencies = [
3535
"click",
36-
"pydantic",
36+
"dotted-notation",
37+
"pydantic>=2.0.0",
3738
"pydantic-settings",
3839
"rich-click",
3940
"rich",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[project]
2+
name = "sample-repo"
3+
version = "0.0.2"
4+
description = ""
5+
authors = [
6+
{name = "Someone", email = "[email protected]"},
7+
]
8+
dependencies = []
9+
requires-python = ">=3.11"
10+
readme = "README.md"
11+
license = {text = "MIT"}
12+
13+
[build-system]
14+
requires = ["setuptools>=61", "wheel"]
15+
build-backend = "setuptools.build_meta"
16+
17+
[tool.pdm.dev-dependencies]
18+
lint = [
19+
"ruff==0.0.292", # Comments should be saved
20+
]
21+
build = [
22+
"bump-my-version>=0.12.0",
23+
]
24+
25+
[tool.bumpversion]
26+
commit = false
27+
tag = false
28+
current_version = "0.0.2"

0 commit comments

Comments
 (0)