Skip to content

Commit

Permalink
Refactored configuration file updating.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
coordt committed Dec 6, 2023
1 parent fc7b961 commit e407974
Show file tree
Hide file tree
Showing 9 changed files with 341 additions and 89 deletions.
8 changes: 6 additions & 2 deletions bumpversion/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
from bumpversion.files import ConfiguredFile
from bumpversion.version_part import Version

from bumpversion.config import Config, update_config_file
from bumpversion.config import Config
from bumpversion.config.files import update_config_file, update_ini_config_file
from bumpversion.exceptions import ConfigurationError
from bumpversion.utils import get_context, key_val_string

Expand Down Expand Up @@ -81,7 +82,10 @@ def do_bump(

configured_files = resolve_file_config(config.files_to_modify, config.version_config)
modify_files(configured_files, version, next_version, ctx, dry_run)
update_config_file(config_file, config.current_version, next_version_str, dry_run)
if config_file and config_file.suffix in {".cfg", ".ini"}:
update_ini_config_file(config_file, config.current_version, next_version_str, dry_run)

Check warning on line 86 in bumpversion/bump.py

View check run for this annotation

Codecov / codecov/patch

bumpversion/bump.py#L86

Added line #L86 was not covered by tests
else:
update_config_file(config_file, config, version, next_version, ctx, dry_run)

ctx = get_context(config, version, next_version)
ctx["new_version"] = next_version_str
Expand Down
76 changes: 4 additions & 72 deletions bumpversion/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
from __future__ import annotations

import logging
import re
from difflib import context_diff
from pathlib import Path
from typing import Union
from typing import TYPE_CHECKING, Union

from bumpversion.config.files import read_config_file
from bumpversion.config.models import Config
from bumpversion.exceptions import ConfigurationError

if TYPE_CHECKING: # pragma: no-coverage
from pathlib import Path

logger = logging.getLogger(__name__)

DEFAULTS = {
Expand Down Expand Up @@ -110,71 +110,3 @@ def check_current_version(config: Config) -> str:
return current_version

raise ConfigurationError("Unable to determine the current version.")

Check warning on line 112 in bumpversion/config/__init__.py

View check run for this annotation

Codecov / codecov/patch

bumpversion/config/__init__.py#L112

Added line #L112 was not covered by tests


def update_config_file(
config_file: Union[str, Path, None], current_version: str, new_version: str, dry_run: bool = False
) -> None:
"""
Update the current_version key in the configuration file.
If no explicit configuration file is passed, it will search in several files to
find its configuration.
Instead of parsing and re-writing the config file with new information, it will use
a regular expression to just replace the current_version value. The idea is it will
avoid unintentional changes (like formatting) to the config file.
Args:
config_file: The configuration file to explicitly use.
current_version: The serialized current version.
new_version: The serialized new version.
dry_run: True if the update should be a dry run.
"""
toml_current_version_regex = re.compile(
f'(?P<section_prefix>\\[tool\\.bumpversion]\n[^[]*current_version\\s*=\\s*)(\\"{current_version}\\")',
re.MULTILINE,
)
cfg_current_version_regex = re.compile(
f"(?P<section_prefix>\\[bumpversion]\n[^[]*current_version\\s*=\\s*)(?P<version>{current_version})",
re.MULTILINE,
)

if not config_file:
logger.info("No configuration file found to update.")
return

config_path = Path(config_file)
existing_config = config_path.read_text()
if config_path.suffix == ".cfg" and cfg_current_version_regex.search(existing_config):
sub_str = f"\\g<section_prefix>{new_version}"
new_config = cfg_current_version_regex.sub(sub_str, existing_config)
elif config_path.suffix == ".toml" and toml_current_version_regex.search(existing_config):
sub_str = f'\\g<section_prefix>"{new_version}"'
new_config = toml_current_version_regex.sub(sub_str, existing_config)
else:
logger.info("Could not find the current version in the config file: %s.", config_path)
return

logger.info(
"%s to config file %s:",
"Would write" if dry_run else "Writing",
config_path,
)

logger.info(
"\n".join(
list(
context_diff(
existing_config.splitlines(),
new_config.splitlines(),
fromfile=f"before {config_path}",
tofile=f"after {config_path}",
lineterm="",
)
)
)
)

if not dry_run:
config_path.write_text(new_config)
109 changes: 108 additions & 1 deletion bumpversion/config/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,17 @@
from __future__ import annotations

import logging
import re
from difflib import context_diff
from pathlib import Path
from typing import Any, Dict, Union
from typing import TYPE_CHECKING, Any, Dict, MutableMapping, Union

from bumpversion.ui import print_warning

if TYPE_CHECKING: # pragma: no-coverage
from bumpversion.config.models import Config
from bumpversion.version_part import Version

logger = logging.getLogger(__name__)

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

return toml_data.get("tool", {}).get("bumpversion", {})


def update_config_file(
config_file: Union[str, Path],
config: Config,
current_version: Version,
new_version: Version,
context: MutableMapping,
dry_run: bool = False,
) -> None:
"""
Update the current_version key in the configuration file.
Args:
config_file: The configuration file to explicitly use.
config: The configuration to use.
current_version: The current version.
new_version: The new version.
context: The context to use for serialization.
dry_run: True if the update should be a dry run.
"""
from bumpversion.config.models import FileConfig
from bumpversion.files import DataFileUpdater

if not config_file:
logger.info("No configuration file found to update.")
return

config_path = Path(config_file)
if config_path.suffix != ".toml":
logger.info("Could not find the current version in the config file: %s.", config_path)
return

Check warning on line 184 in bumpversion/config/files.py

View check run for this annotation

Codecov / codecov/patch

bumpversion/config/files.py#L183-L184

Added lines #L183 - L184 were not covered by tests

# TODO: Eventually this should be transformed into another default "files_to_modify" entry
datafile_config = FileConfig(
filename=str(config_path),
key_path="tool.bumpversion.current_version",
search=config.search,
replace=config.replace,
regex=config.regex,
ignore_missing_version=config.ignore_missing_version,
serialize=config.serialize,
parse=config.parse,
)

updater = DataFileUpdater(datafile_config, config.version_config.part_configs)
updater.update_file(current_version, new_version, context, dry_run)


def update_ini_config_file(
config_file: Union[str, Path], current_version: str, new_version: str, dry_run: bool = False
) -> None:
"""
Update the current_version key in the configuration file.
Instead of parsing and re-writing the config file with new information, it will use
a regular expression to just replace the current_version value. The idea is it will
avoid unintentional changes (like formatting) to the config file.
Args:
config_file: The configuration file to explicitly use.
current_version: The serialized current version.
new_version: The serialized new version.
dry_run: True if the update should be a dry run.
"""
cfg_current_version_regex = re.compile(
f"(?P<section_prefix>\\[bumpversion]\n[^[]*current_version\\s*=\\s*)(?P<version>{current_version})",
re.MULTILINE,
)

config_path = Path(config_file)
existing_config = config_path.read_text()
if config_path.suffix == ".cfg" and cfg_current_version_regex.search(existing_config):
sub_str = f"\\g<section_prefix>{new_version}"
new_config = cfg_current_version_regex.sub(sub_str, existing_config)
else:
logger.info("Could not find the current version in the config file: %s.", config_path)
return

Check warning on line 230 in bumpversion/config/files.py

View check run for this annotation

Codecov / codecov/patch

bumpversion/config/files.py#L229-L230

Added lines #L229 - L230 were not covered by tests

logger.info(
"%s to config file %s:",
"Would write" if dry_run else "Writing",
config_path,
)

logger.info(
"\n".join(
list(
context_diff(
existing_config.splitlines(),
new_config.splitlines(),
fromfile=f"before {config_path}",
tofile=f"after {config_path}",
lineterm="",
)
)
)
)

if not dry_run:
config_path.write_text(new_config)
91 changes: 90 additions & 1 deletion bumpversion/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import re
from copy import deepcopy
from difflib import context_diff
from typing import List, MutableMapping, Optional, Tuple
from pathlib import Path
from typing import Dict, List, MutableMapping, Optional, Tuple

from bumpversion.config.models import FileConfig, VersionPartConfig
from bumpversion.exceptions import VersionNotFoundError
Expand Down Expand Up @@ -248,3 +249,91 @@ def _check_files_contain_version(
for f in files:
context["current_version"] = f.version_config.serialize(current_version, context)
f.contains_version(current_version, context)


class FileUpdater:
"""A class to handle updating files."""

def __init__(
self,
file_cfg: FileConfig,
version_config: VersionConfig,
search: Optional[str] = None,
replace: Optional[str] = None,
) -> None:
self.path = file_cfg.filename
self.version_config = version_config
self.parse = file_cfg.parse or version_config.parse_regex.pattern
self.serialize = file_cfg.serialize or version_config.serialize_formats
self.search = search or file_cfg.search or version_config.search
self.replace = replace or file_cfg.replace or version_config.replace
self.regex = file_cfg.regex or False
self.ignore_missing_version = file_cfg.ignore_missing_version or False
self.version_config = VersionConfig(

Check warning on line 272 in bumpversion/files.py

View check run for this annotation

Codecov / codecov/patch

bumpversion/files.py#L264-L272

Added lines #L264 - L272 were not covered by tests
self.parse, self.serialize, self.search, self.replace, version_config.part_configs
)
self._newlines: Optional[str] = None

Check warning on line 275 in bumpversion/files.py

View check run for this annotation

Codecov / codecov/patch

bumpversion/files.py#L275

Added line #L275 was not covered by tests

def update_file(
self, current_version: Version, new_version: Version, context: MutableMapping, dry_run: bool = False
) -> None:
"""Update the files."""
# TODO: Implement this
pass

Check warning on line 282 in bumpversion/files.py

View check run for this annotation

Codecov / codecov/patch

bumpversion/files.py#L282

Added line #L282 was not covered by tests


class DataFileUpdater:
"""A class to handle updating files."""

def __init__(
self,
file_cfg: FileConfig,
version_part_configs: Dict[str, VersionPartConfig],
) -> None:
self.path = Path(file_cfg.filename)
self.key_path = file_cfg.key_path
self.search = file_cfg.search
self.replace = file_cfg.replace
self.regex = file_cfg.regex
self.ignore_missing_version = file_cfg.ignore_missing_version
self.version_config = VersionConfig(
file_cfg.parse, file_cfg.serialize, file_cfg.search, file_cfg.replace, version_part_configs
)

def update_file(
self, current_version: Version, new_version: Version, context: MutableMapping, dry_run: bool = False
) -> None:
"""Update the files."""
new_context = deepcopy(context)
new_context["current_version"] = self.version_config.serialize(current_version, context)
new_context["new_version"] = self.version_config.serialize(new_version, context)
search_for, raw_search_pattern = get_search_pattern(self.search, new_context, self.regex)
replace_with = self.replace.format(**new_context)
if self.path.suffix == ".toml":
self._update_toml_file(search_for, raw_search_pattern, replace_with, dry_run)

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.key_path)

if value_before is None:
raise KeyError(f"Key path '{self.key_path}' does not exist in {self.path}")

Check warning on line 326 in bumpversion/files.py

View check run for this annotation

Codecov / codecov/patch

bumpversion/files.py#L326

Added line #L326 was not covered by tests
elif not contains_pattern(search_for, value_before) and not self.ignore_missing_version:
raise ValueError(

Check warning on line 328 in bumpversion/files.py

View check run for this annotation

Codecov / codecov/patch

bumpversion/files.py#L328

Added line #L328 was not covered by tests
f"Key '{self.key_path}' in {self.path} does not contain the correct contents: {raw_search_pattern}"
)

new_value = search_for.sub(replace_with, value_before)
log_changes(f"{self.path}:{self.key_path}", value_before, new_value, dry_run)

if dry_run:
return

dotted.update(toml_data, self.key_path, new_value)
self.path.write_text(tomlkit.dumps(toml_data))
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ keywords = ["bumpversion", "version", "release"]
dynamic = ["version"]
dependencies = [
"click",
"pydantic",
"dotted-notation",
"pydantic>=2.0.0",
"pydantic-settings",
"rich-click",
"rich",
Expand Down
28 changes: 28 additions & 0 deletions tests/fixtures/partial_version_strings.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[project]
name = "sample-repo"
version = "0.0.2"
description = ""
authors = [
{name = "Someone", email = "someone@example.com"},
]
dependencies = []
requires-python = ">=3.11"
readme = "README.md"
license = {text = "MIT"}

[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"

[tool.pdm.dev-dependencies]
lint = [
"ruff==0.0.292", # Comments should be saved
]
build = [
"bump-my-version>=0.12.0",
]

[tool.bumpversion]
commit = false
tag = false
current_version = "0.0.2"
Loading

0 comments on commit e407974

Please sign in to comment.