diff --git a/bumpversion/config/__init__.py b/bumpversion/config/__init__.py index 8ec98b44..f0e9e0e1 100644 --- a/bumpversion/config/__init__.py +++ b/bumpversion/config/__init__.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any, Optional -from bumpversion.config.files import read_config_file +from bumpversion.config.files import get_pep621_info, read_config_file from bumpversion.config.models import Config from bumpversion.exceptions import ConfigurationError from bumpversion.ui import get_indented_logger @@ -32,6 +32,7 @@ "message": "Bump version: {current_version} → {new_version}", "moveable_tags": [], "commit_args": None, + "pep621_info": None, "scm_info": None, "parts": {}, "files": [], @@ -84,6 +85,10 @@ def get_configuration(config_file: Optional[Pathlike] = None, **overrides: Any) Config.model_rebuild() config = Config(**config_dict) # type: ignore[arg-type] + # Get the PEP 621 project.version key from pyproject.toml, if possible + config.pep621_info = get_pep621_info(config_file) + logger.debug("config.pep621_info = %r", config.pep621_info) + # Get the information about the SCM scm_info = SCMInfo(SCMConfig.from_config(config)) config.scm_info = scm_info @@ -113,9 +118,12 @@ def check_current_version(config: Config) -> str: ConfigurationError: If it can't find the current version """ current_version = config.current_version + pep621_info = config.pep621_info scm_info = config.scm_info - if current_version is None and scm_info.current_version: + if current_version is None and pep621_info is not None and pep621_info.version: + return pep621_info.version + elif current_version is None and scm_info.current_version: return scm_info.current_version elif current_version and scm_info.current_version and current_version != scm_info.current_version: logger.warning( diff --git a/bumpversion/config/create.py b/bumpversion/config/create.py index 8020d961..587020a8 100644 --- a/bumpversion/config/create.py +++ b/bumpversion/config/create.py @@ -71,6 +71,7 @@ def get_defaults_from_dest(destination: str) -> Tuple[dict, TOMLDocument]: project_config = destination_config.get("project", {}).get("version") config["current_version"] = config["current_version"] or project_config or "0.1.0" del config["scm_info"] + del config["pep621_info"] del config["parts"] del config["files"] diff --git a/bumpversion/config/files.py b/bumpversion/config/files.py index d4daf35d..f81f76c3 100644 --- a/bumpversion/config/files.py +++ b/bumpversion/config/files.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Dict, MutableMapping, Optional, Union from bumpversion.config.files_legacy import read_ini_file +from bumpversion.config.models import PEP621Info from bumpversion.ui import get_indented_logger, print_warning if TYPE_CHECKING: # pragma: no-coverage @@ -23,6 +24,42 @@ ) +def get_pep621_info(config_file: Optional[Pathlike] = None) -> Optional[PEP621Info]: + """Retrieve the PEP 621 `project` table. + + At the moment, only the `project.version` key is handled. Additionally, if the + version is marked as dynamic, then it is explicitly returned as `None`. + + Args: + config_file: + The configuration file to explicitly use. Per PEP 621, this file must be + named `pyproject.toml`. + + Returns: + A `PEP621Info` structure, if given a `pyproject.toml` file to parse, else `None`. + + """ + # We repeat the steps of read_config_file here, except hardcoded to TOML files, and + # without additional error reporting. Then we reimplement read_toml_file, but + # without limiting ourselves to the tool.bumpversion subtable. + if not config_file: + return None + + config_path = Path(config_file) + if not config_path.exists(): + return None + + # PEP 621 explicitly requires pyproject.toml + if config_path.name != "pyproject.toml": + return None + + import tomlkit + + toml_data = tomlkit.parse(config_path.read_text(encoding="utf-8")).unwrap() + project = toml_data.get("project", {}) + return PEP621Info(version=None if "version" in project.get("dynamic", []) else project.get("version")) + + def find_config_file(explicit_file: Optional[Pathlike] = None) -> Union[Path, None]: """ Find the configuration file, if it exists. @@ -139,7 +176,8 @@ def update_config_file( logger.info("You must have a `.toml` suffix to update the config file: %s.", config_path) return - # TODO: Eventually this should be transformed into another default "files_to_modify" entry + # TODO: Eventually this config (and datafile_config_pyprojecttoml below) should be + # transformed into another default "files_to_modify" entry datafile_config = FileChange( filename=str(config_path), key_path="tool.bumpversion.current_version", @@ -154,4 +192,26 @@ def update_config_file( updater = DataFileUpdater(datafile_config, config.version_config.part_configs) updater.update_file(current_version, new_version, context, dry_run) + + # Keep PEP 621 `project.version` consistent with `tool.bumpversion.current_version`. + # (At least, if PEP 621 static `project.version` is in use at all.) + if ( + config_path.name == "pyproject.toml" + and config.pep621_info is not None + and config.pep621_info.version is not None + ): + datafile_config_pyprojecttoml = FileChange( + filename=str(config_path), + key_path="project.version", + search=config.search, + replace=config.replace, + regex=config.regex, + ignore_missing_version=True, + ignore_missing_file=True, + serialize=config.serialize, + parse=config.parse, + ) + updater2 = DataFileUpdater(datafile_config_pyprojecttoml, config.version_config.part_configs) + updater2.update_file(current_version, new_version, context, dry_run) + logger.dedent() diff --git a/bumpversion/config/models.py b/bumpversion/config/models.py index 6d2629a9..6cac6688 100644 --- a/bumpversion/config/models.py +++ b/bumpversion/config/models.py @@ -4,6 +4,7 @@ import re from collections import defaultdict +from dataclasses import dataclass from itertools import chain from typing import TYPE_CHECKING, Dict, List, MutableMapping, Optional, Tuple, Union @@ -97,6 +98,7 @@ class Config(BaseSettings): commit: bool message: str commit_args: Optional[str] + pep621_info: Optional[PEP621Info] scm_info: Optional[SCMInfo] parts: Dict[str, VersionComponentSpec] moveable_tags: list[str] = Field(default_factory=list) @@ -179,3 +181,10 @@ def version_spec(self, version: Optional[str] = None) -> "VersionSpec": from bumpversion.versioning.models import VersionSpec return VersionSpec(self.parts) + + +@dataclass +class PEP621Info: + """PEP 621 info, in particular, the static version number.""" + + version: Optional[str] diff --git a/docs/howtos/multiple-replacements.md b/docs/howtos/multiple-replacements.md index 31a0b971..97aa5619 100644 --- a/docs/howtos/multiple-replacements.md +++ b/docs/howtos/multiple-replacements.md @@ -18,3 +18,7 @@ filename = "CHANGELOG.md" search = "{current_version}...HEAD" replace = "{current_version}...{new_version}" ``` + +??? note "Note: `project.version` in `pyproject.toml`" + + This technique is **not** needed to keep `project.version` in `pyproject.toml` up-to-date if you are storing your Bump My Version configuration in `pyproject.toml` as well. Bump My Version will handle this case automatically. diff --git a/docs/reference/configuration/global.md b/docs/reference/configuration/global.md index 1e90d48a..df1cb52e 100644 --- a/docs/reference/configuration/global.md +++ b/docs/reference/configuration/global.md @@ -80,7 +80,7 @@ If you have pre-commit hooks, add an option to turn off your pre-commit hooks. F ::: field-list required - : **Yes** + : **Yes‡** default : `""` @@ -94,7 +94,11 @@ If you have pre-commit hooks, add an option to turn off your pre-commit hooks. F environment var : `BUMPVERSION_CURRENT_VERSION` -The current version of the software package before bumping. A value for this is required. +The current version of the software package before bumping. A value for this is required, unless a fallback value is found. + +!!! note + + ‡ If `pyproject.toml` exists, then `current_version` falls back to `project.version` in `pyproject.toml`. This only works if `project.version` is statically set. ## ignore_missing_files diff --git a/tests/fixtures/basic_cfg_expected.txt b/tests/fixtures/basic_cfg_expected.txt index 300e0f17..1059b031 100644 --- a/tests/fixtures/basic_cfg_expected.txt +++ b/tests/fixtures/basic_cfg_expected.txt @@ -79,6 +79,7 @@ 'independent': False, 'optional_value': 'gamma', 'values': ['dev', 'gamma']}}, + 'pep621_info': {'version': None}, 'post_commit_hooks': [], 'pre_commit_hooks': [], 'regex': False, diff --git a/tests/fixtures/basic_cfg_expected.yaml b/tests/fixtures/basic_cfg_expected.yaml index 291e4048..759c019b 100644 --- a/tests/fixtures/basic_cfg_expected.yaml +++ b/tests/fixtures/basic_cfg_expected.yaml @@ -108,6 +108,8 @@ parts: values: - "dev" - "gamma" +pep621_info: + version: null post_commit_hooks: pre_commit_hooks: diff --git a/tests/fixtures/basic_cfg_expected_full.json b/tests/fixtures/basic_cfg_expected_full.json index a0ec9dc3..71f5b6e2 100644 --- a/tests/fixtures/basic_cfg_expected_full.json +++ b/tests/fixtures/basic_cfg_expected_full.json @@ -122,6 +122,9 @@ ] } }, + "pep621_info": { + "version": null + }, "post_commit_hooks": [], "pre_commit_hooks": [], "regex": false, diff --git a/tests/test_bump.py b/tests/test_bump.py index 24996542..5bfc50bd 100644 --- a/tests/test_bump.py +++ b/tests/test_bump.py @@ -385,6 +385,248 @@ def test_key_path_required_for_toml_change(tmp_path: Path, caplog): ) +def test_pep621_fallback_works_static_case(tmp_path: Path, caplog): + """If tool.bumpversion.current_version is not defined, but static project.version is, use that.""" + from click.testing import CliRunner, Result + + from bumpversion import cli, config + + # Arrange + config_path = tmp_path / "pyproject.toml" + config_path.write_text( + dedent( + """ + [project] + version = "0.1.26" + + [tool.bumpversion] + allow_dirty = true + commit = true + + [tool.othertool] + bake_cookies = true + ignore-words-list = "sugar, salt, flour" + + [tool.other-othertool] + version = "0.1.26" + """ + ), + encoding="utf-8", + ) + + conf = config.get_configuration(config_file=config_path) + + # Act + runner: CliRunner = CliRunner() + with inside_dir(tmp_path): + result: Result = runner.invoke( + cli.cli, + ["bump", "-vv", "minor"], + ) + + if result.exit_code != 0: + print(caplog.text) + print("Here is the output:") + print(result.output) + import traceback + + print(traceback.print_exception(result.exc_info[1])) + + # Assert + assert result.exit_code == 0 + assert config_path.read_text() == dedent( + """ + [project] + version = "0.2.0" + + [tool.bumpversion] + allow_dirty = true + commit = true + + [tool.othertool] + bake_cookies = true + ignore-words-list = "sugar, salt, flour" + + [tool.other-othertool] + version = "0.1.26" + """ + ) + + +def test_pep621_fallback_works_dynamic_case(tmp_path: Path, caplog): + """If tool.bumpversion.current_version is not defined, but dynamic project.version is, *don't* use that.""" + from click.testing import CliRunner, Result + + from bumpversion import cli, config + from bumpversion.exceptions import ConfigurationError + + # Arrange + config_path = tmp_path / "pyproject.toml" + config_path.write_text( + dedent( + """ + [project] + dynamic = ["version"] + + [tool.bumpversion] + allow_dirty = true + commit = true + + [tool.othertool] + bake_cookies = true + ignore-words-list = "sugar, salt, flour" + + [tool.other-othertool] + version = "0.1.26" + """ + ), + encoding="utf-8", + ) + + with pytest.raises(ConfigurationError): + config.get_configuration(config_file=config_path) + + +def test_pep621_fallback_always_updated(tmp_path: Path, caplog): + """Always update project.version (if static).""" + from click.testing import CliRunner, Result + + from bumpversion import cli, config + + # Arrange + config_path = tmp_path / "pyproject.toml" + config_path.write_text( + dedent( + """ + [project] + version = "0.1.26" + + [tool.bumpversion] + current_version = "0.1.26" + allow_dirty = true + commit = true + + [tool.othertool] + bake_cookies = true + ignore-words-list = "sugar, salt, flour" + + [tool.other-othertool] + version = "0.1.26" + """ + ), + encoding="utf-8", + ) + + conf = config.get_configuration(config_file=config_path) + + # Act + runner: CliRunner = CliRunner() + with inside_dir(tmp_path): + result: Result = runner.invoke( + cli.cli, + ["bump", "-vv", "minor"], + ) + + if result.exit_code != 0: + print(caplog.text) + print("Here is the output:") + print(result.output) + import traceback + + print(traceback.print_exception(result.exc_info[1])) + + # Assert + assert result.exit_code == 0 + assert config_path.read_text() == dedent( + """ + [project] + version = "0.2.0" + + [tool.bumpversion] + current_version = "0.2.0" + allow_dirty = true + commit = true + + [tool.othertool] + bake_cookies = true + ignore-words-list = "sugar, salt, flour" + + [tool.other-othertool] + version = "0.1.26" + """ + ) + + +def test_pep621_fallback_never_updated_if_dynamic(tmp_path: Path, caplog): + """Never update project.version if dynamic.""" + from click.testing import CliRunner, Result + + from bumpversion import cli, config + + # Arrange + config_path = tmp_path / "pyproject.toml" + config_path.write_text( + dedent( + """ + [project] + dynamic = ["version"] + + [tool.bumpversion] + current_version = "0.1.26" + allow_dirty = true + commit = true + + [tool.othertool] + bake_cookies = true + ignore-words-list = "sugar, salt, flour" + + [tool.other-othertool] + version = "0.1.26" + """ + ), + encoding="utf-8", + ) + + conf = config.get_configuration(config_file=config_path) + + # Act + runner: CliRunner = CliRunner() + with inside_dir(tmp_path): + result: Result = runner.invoke( + cli.cli, + ["bump", "-vv", "minor"], + ) + + if result.exit_code != 0: + print(caplog.text) + print("Here is the output:") + print(result.output) + import traceback + + print(traceback.print_exception(result.exc_info[1])) + + # Assert + assert result.exit_code == 0 + assert config_path.read_text() == dedent( + """ + [project] + dynamic = ["version"] + + [tool.bumpversion] + current_version = "0.2.0" + allow_dirty = true + commit = true + + [tool.othertool] + bake_cookies = true + ignore-words-list = "sugar, salt, flour" + + [tool.other-othertool] + version = "0.1.26" + """ + ) + + def test_changes_to_files_are_committed(git_repo: Path, caplog): """Any files changed during the bump are committed.""" from click.testing import CliRunner, Result diff --git a/tests/test_config/test_create.py b/tests/test_config/test_create.py index 90cd129b..d9db6305 100644 --- a/tests/test_config/test_create.py +++ b/tests/test_config/test_create.py @@ -36,9 +36,10 @@ @pytest.fixture def default_config() -> dict: - """The default configuration with the scm_info and parts removed.""" + """The default configuration with the scm_info, pep621_info and parts removed.""" defaults = DEFAULTS.copy() del defaults["scm_info"] + del defaults["pep621_info"] del defaults["parts"] del defaults["files"] return defaults diff --git a/tests/test_config/test_init.py b/tests/test_config/test_init.py index 94503595..5e608fcc 100644 --- a/tests/test_config/test_init.py +++ b/tests/test_config/test_init.py @@ -1,8 +1,12 @@ """Tests for the config.__init__ module.""" +from typing import Optional from unittest.mock import Mock +import pytest + from bumpversion.config import Config, check_current_version +from bumpversion.config.models import PEP621Info from bumpversion.scm.models import SCMConfig, SCMInfo @@ -14,13 +18,29 @@ class TestCheckCurrentVersion: - tag may not match current version in config """ - def test_uses_tag_when_missing_current_version(self, scm_config: SCMConfig): - """When the config does not have a current_version, the last tag is used.""" + def test_uses_pep621_version_when_missing_current_version(self, scm_config: SCMConfig): + """When the config does not have a current_version, the PEP 621 project.version is used.""" + # Arrange + + pep621_info = PEP621Info(version="1.2.4") + scm_info = SCMInfo(scm_config) + scm_info.current_version = "1.2.3" + config = Mock(spec=Config, current_version=None, pep621_info=pep621_info, scm_info=scm_info) + + # Act + result = check_current_version(config) + + # Assert + assert result == "1.2.4" + + @pytest.mark.parametrize("pep621_info", [PEP621Info(version=None), None]) + def test_uses_tag_when_missing_current_version(self, scm_config: SCMConfig, pep621_info: Optional[PEP621Info]): + """When the config has neither current_version nor PEP 621 project.version, the last tag is used.""" # Arrange scm_info = SCMInfo(scm_config) scm_info.current_version = "1.2.3" - config = Mock(spec=Config, current_version=None, scm_info=scm_info) + config = Mock(spec=Config, current_version=None, pep621_info=pep621_info, scm_info=scm_info) # Act result = check_current_version(config) diff --git a/tests/test_config/test_utils.py b/tests/test_config/test_utils.py index 838e1861..78cef962 100644 --- a/tests/test_config/test_utils.py +++ b/tests/test_config/test_utils.py @@ -19,6 +19,7 @@ def write_config(tmp_path: Path, overrides: dict) -> Path: defaults = DEFAULTS.copy() defaults.pop("parts") defaults.pop("scm_info") + defaults.pop("pep621_info") defaults["current_version"] = defaults["current_version"] or "1.2.3" defaults["commit_args"] = "" config = {"tool": {"bumpversion": {**defaults, **overrides}}}