diff --git a/bumpversion/bump.py b/bumpversion/bump.py index 7ad24af3..595be314 100644 --- a/bumpversion/bump.py +++ b/bumpversion/bump.py @@ -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 @@ -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) + 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 diff --git a/bumpversion/config/__init__.py b/bumpversion/config/__init__.py index c5fab8ce..21cbda1d 100644 --- a/bumpversion/config/__init__.py +++ b/bumpversion/config/__init__.py @@ -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 = { @@ -110,71 +110,3 @@ def check_current_version(config: Config) -> str: return current_version raise ConfigurationError("Unable to determine the current version.") - - -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\\[tool\\.bumpversion]\n[^[]*current_version\\s*=\\s*)(\\"{current_version}\\")', - re.MULTILINE, - ) - cfg_current_version_regex = re.compile( - f"(?P\\[bumpversion]\n[^[]*current_version\\s*=\\s*)(?P{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{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"{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) diff --git a/bumpversion/config/files.py b/bumpversion/config/files.py index c768f471..124ddb92 100644 --- a/bumpversion/config/files.py +++ b/bumpversion/config/files.py @@ -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 = ( @@ -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 + + # 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\\[bumpversion]\n[^[]*current_version\\s*=\\s*)(?P{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{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 + + 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) diff --git a/bumpversion/files.py b/bumpversion/files.py index afcbf6ee..855e786f 100644 --- a/bumpversion/files.py +++ b/bumpversion/files.py @@ -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 @@ -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( + self.parse, self.serialize, self.search, self.replace, version_config.part_configs + ) + self._newlines: Optional[str] = None + + def update_file( + self, current_version: Version, new_version: Version, context: MutableMapping, dry_run: bool = False + ) -> None: + """Update the files.""" + # TODO: Implement this + pass + + +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}") + elif not contains_pattern(search_for, value_before) and not self.ignore_missing_version: + raise ValueError( + 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)) diff --git a/pyproject.toml b/pyproject.toml index 9c277bbc..c3fa388e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,8 @@ keywords = ["bumpversion", "version", "release"] dynamic = ["version"] dependencies = [ "click", - "pydantic", + "dotted-notation", + "pydantic>=2.0.0", "pydantic-settings", "rich-click", "rich", diff --git a/tests/fixtures/partial_version_strings.toml b/tests/fixtures/partial_version_strings.toml new file mode 100644 index 00000000..542f485a --- /dev/null +++ b/tests/fixtures/partial_version_strings.toml @@ -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" diff --git a/tests/test_bump.py b/tests/test_bump.py index 66f584d5..0affee64 100644 --- a/tests/test_bump.py +++ b/tests/test_bump.py @@ -81,9 +81,10 @@ def test_do_bump_with_version_part(mock_update_config_file, mock_modify_files): "bar.txt", } assert mock_update_config_file.call_args[0][0] is None - assert mock_update_config_file.call_args[0][1] == config.current_version - assert mock_update_config_file.call_args[0][2] == "2.0.0" - assert mock_update_config_file.call_args[0][3] is False + assert mock_update_config_file.call_args[0][1] == config + assert mock_update_config_file.call_args[0][2] == current_version + assert mock_update_config_file.call_args[0][3] == current_version.bump(version_part, version_config.order) + assert mock_update_config_file.call_args[0][5] is False @patch("bumpversion.files.modify_files") @@ -111,9 +112,10 @@ def test_do_bump_with_new_version(mock_update_config_file, mock_modify_files): mock_update_config_file.assert_called_once() assert mock_update_config_file.call_args[0][0] is None - assert mock_update_config_file.call_args[0][1] is config.current_version - assert mock_update_config_file.call_args[0][2] == "2.0.0" - assert mock_update_config_file.call_args[0][3] is True + assert mock_update_config_file.call_args[0][1] == config + assert mock_update_config_file.call_args[0][2] == current_version + assert mock_update_config_file.call_args[0][3] == version_config.parse(new_version) + assert mock_update_config_file.call_args[0][5] is dry_run @patch("bumpversion.files.modify_files") diff --git a/tests/test_config.py b/tests/test_config.py index 71a452d7..6516c7ce 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -10,6 +10,7 @@ import bumpversion.config.files import bumpversion.config.utils +from bumpversion.utils import get_context from bumpversion import config from tests.conftest import inside_dir, get_config_data @@ -177,23 +178,53 @@ def test_utf8_message_from_config_file(tmp_path: Path, cfg_file): @pytest.mark.parametrize( - ["cfg_file_name", "expected_diff"], [ - (".bumpversion.cfg", CFG_EXPECTED_DIFF), - ("setup.cfg", CFG_EXPECTED_DIFF), - ("pyproject.toml", TOML_EXPECTED_DIFF), + "cfg_file_name", + ], + [ + ("pyproject.toml",), + ], +) +def test_update_config_file(tmp_path: Path, cfg_file_name: str, fixtures_path: Path) -> None: + """ + Make sure only the version string is updated in the config file. + """ + expected_diff = TOML_EXPECTED_DIFF + cfg_path = tmp_path / cfg_file_name + orig_path = fixtures_path / f"basic_cfg{cfg_path.suffix}" + cfg_path.write_text(orig_path.read_text()) + original_content = orig_path.read_text().splitlines(keepends=True) + with inside_dir(tmp_path): + cfg = config.get_configuration(cfg_path) + ctx = get_context(cfg) + current_version = cfg.version_config.parse("1.0.0") + new_version = cfg.version_config.parse("1.0.1") + bumpversion.config.files.update_config_file(cfg_path, cfg, current_version, new_version, ctx) + new_content = cfg_path.read_text().splitlines(keepends=True) + difference = difflib.context_diff(original_content, new_content, n=0) + assert "".join(difference) == expected_diff + + +@pytest.mark.parametrize( + [ + "cfg_file_name", + ], + [ + (".bumpversion.cfg",), + ("setup.cfg",), ], ) -def test_update_config_file(tmp_path: Path, cfg_file_name: str, expected_diff: str, fixtures_path: Path) -> None: +def test_update_ini_config_file(tmp_path: Path, cfg_file_name: str, fixtures_path: Path) -> None: """ Make sure only the version string is updated in the config file. """ + expected_diff = CFG_EXPECTED_DIFF cfg_path = tmp_path / cfg_file_name orig_path = fixtures_path / f"basic_cfg{cfg_path.suffix}" cfg_path.write_text(orig_path.read_text()) original_content = orig_path.read_text().splitlines(keepends=True) - config.update_config_file(cfg_path, "1.0.0", "1.0.1") + bumpversion.config.files.update_ini_config_file(cfg_path, "1.0.0", "1.0.1") new_content = cfg_path.read_text().splitlines(keepends=True) difference = difflib.context_diff(original_content, new_content, n=0) assert "".join(difference) == expected_diff diff --git a/tests/test_files.py b/tests/test_files.py index f7344b38..3227bb05 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -2,13 +2,16 @@ import os import shutil from datetime import datetime, timezone +from difflib import context_diff from pathlib import Path from textwrap import dedent +import tomlkit import pytest from pytest import param from bumpversion import exceptions, files, config, bump +from bumpversion.config.models import FileConfig from bumpversion.utils import get_context from bumpversion.exceptions import VersionNotFoundError from bumpversion.version_part import VersionConfig @@ -429,3 +432,58 @@ def test_bad_regex_search(tmp_path: Path, caplog) -> None: # Assert assert version_path.read_text() == "Score: A+ ( '1.2.4'" 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) + + 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 = FileConfig( + 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"