diff --git a/bumpversion/bump.py b/bumpversion/bump.py index 2d135e08..b386aa9a 100644 --- a/bumpversion/bump.py +++ b/bumpversion/bump.py @@ -4,6 +4,8 @@ from pathlib import Path from typing import TYPE_CHECKING, List, MutableMapping, Optional +from bumpversion.hooks import run_post_commit_hooks, run_pre_commit_hooks, run_setup_hooks + if TYPE_CHECKING: # pragma: no-coverage from bumpversion.files import ConfiguredFile from bumpversion.versioning.models import Version @@ -75,10 +77,14 @@ def do_bump( logger.indent() ctx = get_context(config) + logger.info("Parsing current version '%s'", config.current_version) logger.indent() version = config.version_config.parse(config.current_version) logger.dedent() + + run_setup_hooks(config, version, dry_run) + next_version = get_next_version(version, config, version_part, new_version) next_version_str = config.version_config.serialize(next_version, ctx) logger.info("New version will be '%s'", next_version_str) @@ -109,7 +115,13 @@ def do_bump( ctx = get_context(config, version, next_version) ctx["new_version"] = next_version_str + + run_pre_commit_hooks(config, version, next_version, dry_run) + commit_and_tag(config, config_file, configured_files, ctx, dry_run) + + run_post_commit_hooks(config, version, next_version, dry_run) + logger.info("Done.") diff --git a/bumpversion/config/__init__.py b/bumpversion/config/__init__.py index 1195b757..19eec174 100644 --- a/bumpversion/config/__init__.py +++ b/bumpversion/config/__init__.py @@ -34,6 +34,9 @@ "scm_info": None, "parts": {}, "files": [], + "setup_hooks": [], + "pre_commit_hooks": [], + "post_commit_hooks": [], } diff --git a/bumpversion/config/models.py b/bumpversion/config/models.py index 00e630ad..d6ce3ef5 100644 --- a/bumpversion/config/models.py +++ b/bumpversion/config/models.py @@ -100,6 +100,9 @@ class Config(BaseSettings): scm_info: Optional["SCMInfo"] parts: Dict[str, VersionComponentSpec] files: List[FileChange] = Field(default_factory=list) + setup_hooks: List[str] = Field(default_factory=list) + pre_commit_hooks: List[str] = Field(default_factory=list) + post_commit_hooks: List[str] = Field(default_factory=list) included_paths: List[str] = Field(default_factory=list) excluded_paths: List[str] = Field(default_factory=list) model_config = SettingsConfigDict(env_prefix="bumpversion_") diff --git a/bumpversion/hooks.py b/bumpversion/hooks.py new file mode 100644 index 00000000..3238c019 --- /dev/null +++ b/bumpversion/hooks.py @@ -0,0 +1,138 @@ +"""Implementation of the hook interface.""" + +import datetime +import os +import subprocess +from typing import Dict, List, Optional + +from bumpversion.config.models import Config +from bumpversion.ui import get_indented_logger +from bumpversion.versioning.models import Version + +PREFIX = "BVHOOK_" + +logger = get_indented_logger(__name__) + + +def run_command(script: str, environment: Optional[dict] = None) -> subprocess.CompletedProcess: + """Runs command-line programs using the shell.""" + if not isinstance(script, str): + raise TypeError(f"`script` must be a string, not {type(script)}") + if environment and not isinstance(environment, dict): + raise TypeError(f"`environment` must be a dict, not {type(environment)}") + return subprocess.run(script, env=environment, encoding="utf-8", shell=True, text=True, capture_output=True) + + +def base_env(config: Config) -> Dict[str, str]: + """Provide the base environment variables.""" + return { + f"{PREFIX}NOW": datetime.datetime.now().isoformat(), + f"{PREFIX}UTCNOW": datetime.datetime.now(datetime.timezone.utc).isoformat(), + **os.environ, + **scm_env(config), + } + + +def scm_env(config: Config) -> Dict[str, str]: + """Provide the scm environment variables.""" + scm = config.scm_info + return { + f"{PREFIX}COMMIT_SHA": scm.commit_sha or "", + f"{PREFIX}DISTANCE_TO_LATEST_TAG": str(scm.distance_to_latest_tag) or "0", + f"{PREFIX}IS_DIRTY": str(scm.dirty), + f"{PREFIX}BRANCH_NAME": scm.branch_name or "", + f"{PREFIX}SHORT_BRANCH_NAME": scm.short_branch_name or "", + f"{PREFIX}CURRENT_VERSION": scm.current_version or "", + f"{PREFIX}CURRENT_TAG": scm.current_tag or "", + } + + +def version_env(version: Version, version_prefix: str) -> Dict[str, str]: + """Provide the environment variables for each version component with a prefix.""" + return {f"{PREFIX}{version_prefix}{part.upper()}": version[part].value for part in version} + + +def get_setup_hook_env(config: Config, current_version: Version) -> Dict[str, str]: + """Provide the environment dictionary for `setup_hook`s.""" + return {**base_env(config), **scm_env(config), **version_env(current_version, "CURRENT_")} + + +def get_pre_commit_hook_env(config: Config, current_version: Version, new_version: Version) -> Dict[str, str]: + """Provide the environment dictionary for `pre_commit_hook`s.""" + return { + **base_env(config), + **scm_env(config), + **version_env(current_version, "CURRENT_"), + **version_env(new_version, "NEW_"), + } + + +def get_post_commit_hook_env(config: Config, current_version: Version, new_version: Version) -> Dict[str, str]: + """Provide the environment dictionary for `post_commit_hook`s.""" + return { + **base_env(config), + **scm_env(config), + **version_env(current_version, "CURRENT_"), + **version_env(new_version, "NEW_"), + } + + +def run_hooks(hooks: List[str], env: Dict[str, str], dry_run: bool = False) -> None: + """Run a list of command-line programs using the shell.""" + logger.indent() + for script in hooks: + if dry_run: + logger.debug(f"Would run {script!r}") + continue + logger.debug(f"Running {script!r}") + logger.indent() + result = run_command(script, env) + logger.debug(result.stdout) + logger.debug(result.stderr) + logger.debug(f"Exited with {result.returncode}") + logger.indent() + logger.dedent() + + +def run_setup_hooks(config: Config, current_version: Version, dry_run: bool = False) -> None: + """Run the setup hooks.""" + env = get_setup_hook_env(config, current_version) + if config.setup_hooks: + running = "Would run" if dry_run else "Running" + logger.info(f"{running} setup hooks:") + else: + logger.info("No setup hooks defined") + return + + run_hooks(config.setup_hooks, env, dry_run) + + +def run_pre_commit_hooks( + config: Config, current_version: Version, new_version: Version, dry_run: bool = False +) -> None: + """Run the pre-commit hooks.""" + env = get_pre_commit_hook_env(config, current_version, new_version) + + if config.pre_commit_hooks: + running = "Would run" if dry_run else "Running" + logger.info(f"{running} pre-commit hooks:") + else: + logger.info("No pre-commit hooks defined") + return + + run_hooks(config.pre_commit_hooks, env, dry_run) + + +def run_post_commit_hooks( + config: Config, current_version: Version, new_version: Version, dry_run: bool = False +) -> None: + """Run the post-commit hooks.""" + env = get_post_commit_hook_env(config, current_version, new_version) + if config.post_commit_hooks: + running = "Would run" if dry_run else "Running" + logger.info(f"{running} post-commit hooks:") + else: + logger.info("No post-commit hooks defined") + return + + run_hooks(config.post_commit_hooks, env, dry_run) diff --git a/bumpversion/scm.py b/bumpversion/scm.py index c8cd6d83..ae540248 100644 --- a/bumpversion/scm.py +++ b/bumpversion/scm.py @@ -27,6 +27,7 @@ class SCMInfo: commit_sha: Optional[str] = None distance_to_latest_tag: int = 0 current_version: Optional[str] = None + current_tag: Optional[str] = None branch_name: Optional[str] = None short_branch_name: Optional[str] = None repository_root: Optional[Path] = None @@ -42,6 +43,7 @@ def __repr__(self): f"commit_sha={self.commit_sha}, " f"distance_to_latest_tag={self.distance_to_latest_tag}, " f"current_version={self.current_version}, " + f"current_tag={self.current_tag}, " f"branch_name={self.branch_name}, " f"short_branch_name={self.short_branch_name}, " f"repository_root={self.repository_root}, " @@ -286,7 +288,7 @@ def _commit_info(cls, parse_pattern: str, tag_name: str) -> dict: A dictionary containing information about the latest commit. """ tag_pattern = tag_name.replace("{new_version}", "*") - info = dict.fromkeys(["dirty", "commit_sha", "distance_to_latest_tag", "current_version"]) + info = dict.fromkeys(["dirty", "commit_sha", "distance_to_latest_tag", "current_version", "current_tag"]) info["distance_to_latest_tag"] = 0 try: # get info about the latest tag in git @@ -309,6 +311,7 @@ def _commit_info(cls, parse_pattern: str, tag_name: str) -> dict: info["commit_sha"] = describe_out.pop().lstrip("g") info["distance_to_latest_tag"] = int(describe_out.pop()) + info["current_tag"] = "-".join(describe_out) version = cls.get_version_from_tag("-".join(describe_out), tag_name, parse_pattern) info["current_version"] = version or "-".join(describe_out).lstrip("v") except subprocess.CalledProcessError as e: diff --git a/docs/reference/hooks.md b/docs/reference/hooks.md new file mode 100644 index 00000000..d18a245c --- /dev/null +++ b/docs/reference/hooks.md @@ -0,0 +1,139 @@ +--- +title: Hooks +description: Details about writing and setting up hooks +icon: +date: 2024-08-15 +comments: true +--- +# Hooks + +## Hook Suites + +A _hook suite_ is a list of _hooks_ to run sequentially. A _hook_ is either an individual shell command or an executable script. + +There are three hook suites: _setup, pre-commit,_ and _post-commit._ During the version increment process this is the order of operations: + +1. Run _setup_ hooks +2. Increment version +3. Change files +4. Run _pre-commit_ hooks +5. Commit and tag +6. Run _post-commit_ hooks + +!!! Note + + Don't confuse the _pre-commit_ and _post-commit_ hook suites with Git pre- and post-commit hooks. Those hook suites are named for their adjacency to the commit and tag operation. + + +## Configuration + +Configure each hook suite with the `setup_hooks`, `pre_commit_hooks`, or `post_commit_hooks` keys. + +Each suite takes a list of strings. The strings may be individual commands: + +```toml title="Calling individual commands" +[tool.bumpversion] +setup_hooks = [ + "git config --global user.email \"bump-my-version@github.actions\"", + "git config --global user.name \"Testing Git\"", + "git --version", + "git config --list", +] +pre_commit_hooks = ["cat CHANGELOG.md"] +post_commit_hooks = ["echo Done"] +``` + +or the path to an executable script: + +```toml title="Calling a shell script" +[tool.bumpversion] +setup_hooks = ["path/to/setup.sh"] +pre_commit_hooks = ["path/to/pre-commit.sh"] +post_commit_hooks = ["path/to/post-commit.sh"] +``` + +!!! Note + + You can make a script executable using the following steps: + + 1. Add a [shebang](https://en.wikipedia.org/wiki/Shebang_(Unix)) line to the top like `#!/bin/bash` + 2. Run `chmod u+x path/to/script.sh` to set the executable bit + +## Hook Environments + +Each hook has these environment variables set when executed. + +### Inherited environment + +All environment variables set before bump-my-version was run are available. + +### Date and time fields + +::: field-list + + `BVHOOK_NOW` + : The ISO-8601-formatted current local time without a time zone reference. + + `BVHOOK_UTCNOW` + : The ISO-8601-formatted current local time in the UTC time zone. + +### Source code management fields + +!!! Note + + These fields will only have values if the code is in a Git or Mercurial repository. + +::: field-list + + `BVHOOK_COMMIT_SHA` + : The latest commit reference. + + `BHOOK_DISTANCE_TO_LATEST_TAG` + : The number of commits since the latest tag. + + `BVHOOK_IS_DIRTY` + : A boolean indicating if the current repository has pending changes. + + `BVHOOK_BRANCH_NAME` + : The current branch name. + + `BVHOOK_SHORT_BRANCH_NAME` + : The current branch name, converted to lowercase, with non-alphanumeric characters removed and truncated to 20 characters. For example, `feature/MY-long_branch-name` would become `featuremylongbranchn`. + + +### Current version fields + +::: field-list + `BVHOOK_CURRENT_VERSION` + : The current version serialized as a string + + `BVHOOK_CURRENT_TAG` + : The current tag + + `BVHOOK_CURRENT_` + : Each version component defined by the [version configuration parsing regular expression](configuration/global.md#parse). The default configuration would have `BVHOOK_CURRENT_MAJOR`, `BVHOOK_CURRENT_MINOR`, and `BVHOOK_CURRENT_PATCH` available. + + +### New version fields + +!!! Note + + These are not available in the _setup_ hook suite. + +::: field-list + `BVHOOK_NEW_VERSION` + : The new version serialized as a string + + `BVHOOK_NEW_TAG` + : The new tag + + `BVHOOK_NEW_` + : Each version component defined by the [version configuration parsing regular expression](configuration/global.md#parse). The default configuration would have `BVHOOK_NEW_MAJOR`, `BVHOOK_NEW_MINOR`, and `BVHOOK_NEW_PATCH` available. + +## Outputs + +The `stdout` and `stderr` streams are echoed to the console if you pass the `-vv` option. + +## Dry-runs + +Bump my version does not execute any hooks during a dry run. With the verbose output option it will state which hooks it would have run. diff --git a/pyproject.toml b/pyproject.toml index 32aa90cb..f99076e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -186,7 +186,7 @@ line-length = 119 select = ["E", "W", "F", "I", "N", "B", "BLE", "C", "D", "E", "F", "I", "N", "S", "T", "W", "RUF", "NPY", "PD", "PGH", "ANN", "C90", "PLC", "PLE", "PLW", "TCH"] ignore = [ "ANN002", "ANN003", "ANN101", "ANN102", "ANN204", "ANN401", - "S101", "S104", + "S101", "S104", "S602", "D105", "D106", "D107", "D200", "D212", "PD011", "PLW1510", diff --git a/tests/conftest.py b/tests/conftest.py index bf0198b2..680b5a55 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,8 @@ import pytest +from bumpversion.versioning.models import Version + @pytest.fixture def tests_path() -> Path: @@ -51,6 +53,12 @@ def get_config_data(overrides: dict) -> tuple: return conf, version_config, version +def get_semver(version: str) -> Version: + """Get a semantic version from a string.""" + _, _, version = get_config_data({"current_version": version}) + return version + + @pytest.fixture def git_repo(tmp_path: Path) -> Path: """Generate a simple temporary git repo and return the path.""" diff --git a/tests/fixtures/basic_cfg_expected.txt b/tests/fixtures/basic_cfg_expected.txt index 6c738d33..0e0fb5d4 100644 --- a/tests/fixtures/basic_cfg_expected.txt +++ b/tests/fixtures/basic_cfg_expected.txt @@ -78,10 +78,13 @@ 'independent': False, 'optional_value': 'gamma', 'values': ['dev', 'gamma']}}, + 'post_commit_hooks': [], + 'pre_commit_hooks': [], 'regex': False, 'replace': '{new_version}', 'scm_info': {'branch_name': None, 'commit_sha': None, + 'current_tag': None, 'current_version': None, 'dirty': None, 'distance_to_latest_tag': 0, @@ -90,6 +93,7 @@ 'tool': None}, 'search': '{current_version}', 'serialize': ('{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'), + 'setup_hooks': [], 'sign_tags': False, 'tag': True, 'tag_message': 'Bump version: {current_version} → {new_version}', diff --git a/tests/fixtures/basic_cfg_expected.yaml b/tests/fixtures/basic_cfg_expected.yaml index 371e8709..9c35a1d9 100644 --- a/tests/fixtures/basic_cfg_expected.yaml +++ b/tests/fixtures/basic_cfg_expected.yaml @@ -106,11 +106,16 @@ parts: values: - "dev" - "gamma" +post_commit_hooks: + +pre_commit_hooks: + regex: false replace: "{new_version}" scm_info: branch_name: null commit_sha: null + current_tag: null current_version: null dirty: null distance_to_latest_tag: 0 @@ -121,6 +126,8 @@ search: "{current_version}" serialize: - "{major}.{minor}.{patch}-{release}" - "{major}.{minor}.{patch}" +setup_hooks: + sign_tags: false tag: true tag_message: "Bump version: {current_version} → {new_version}" diff --git a/tests/fixtures/basic_cfg_expected_full.json b/tests/fixtures/basic_cfg_expected_full.json index 3b4d31ba..df4dadeb 100644 --- a/tests/fixtures/basic_cfg_expected_full.json +++ b/tests/fixtures/basic_cfg_expected_full.json @@ -121,11 +121,14 @@ ] } }, + "post_commit_hooks": [], + "pre_commit_hooks": [], "regex": false, "replace": "{new_version}", "scm_info": { "branch_name": null, "commit_sha": null, + "current_tag": null, "current_version": null, "dirty": null, "distance_to_latest_tag": 0, @@ -138,6 +141,7 @@ "{major}.{minor}.{patch}-{release}", "{major}.{minor}.{patch}" ], + "setup_hooks": [], "sign_tags": false, "tag": true, "tag_message": "Bump version: {current_version} \u2192 {new_version}", diff --git a/tests/test_hooks/__init__.py b/tests/test_hooks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_hooks/test_envs.py b/tests/test_hooks/test_envs.py new file mode 100644 index 00000000..3570b7f2 --- /dev/null +++ b/tests/test_hooks/test_envs.py @@ -0,0 +1,88 @@ +"""Tests for environment generation for hooks.""" + +import datetime +import os +import subprocess +from pathlib import Path + +from bumpversion.hooks import scm_env, PREFIX, base_env, version_env +from tests.conftest import inside_dir, get_config_data + + +def test_scm_env_returns_correct_info(git_repo: Path): + """Should return information about the latest tag.""" + readme = git_repo.joinpath("readme.md") + readme.touch() + tag_prefix = "v" + overrides = {"current_version": "0.1.0", "commit": True, "tag": True, "tag_name": f"{tag_prefix}{{new_version}}"} + + with inside_dir(git_repo): + # Add a file and tag + subprocess.run(["git", "add", "readme.md"]) + subprocess.run(["git", "commit", "-m", "first"]) + subprocess.run(["git", "tag", f"{tag_prefix}0.1.0"]) + conf, _, _ = get_config_data(overrides) + + result = scm_env(conf) + assert result[f"{PREFIX}BRANCH_NAME"] == "master" + assert len(result[f"{PREFIX}COMMIT_SHA"]) == 40 + assert result[f"{PREFIX}CURRENT_TAG"] == "v0.1.0" + assert result[f"{PREFIX}CURRENT_VERSION"] == "0.1.0" + assert result[f"{PREFIX}DISTANCE_TO_LATEST_TAG"] == "0" + assert result[f"{PREFIX}IS_DIRTY"] == "False" + assert result[f"{PREFIX}SHORT_BRANCH_NAME"] == "master" + + +class MockDatetime(datetime.datetime): + @classmethod + def now(cls, tz=None): + return cls(2022, 2, 1, 17) if tz else cls(2022, 2, 1, 12) + + +class TestBaseEnv: + """Tests for base_env function.""" + + def test_includes_now_and_utcnow(self, mocker): + """The output includes NOW and UTCNOW.""" + mocker.patch("datetime.datetime", new=MockDatetime) + config, _, _ = get_config_data({"current_version": "0.1.0"}) + result_env = base_env(config) + + assert f"{PREFIX}NOW" in result_env + assert f"{PREFIX}UTCNOW" in result_env + assert result_env[f"{PREFIX}NOW"] == "2022-02-01T12:00:00" + assert result_env[f"{PREFIX}UTCNOW"] == "2022-02-01T17:00:00" + + def test_includes_os_environ(self): + """The output includes the current process' environment.""" + config, _, _ = get_config_data({"current_version": "0.1.0"}) + result_env = base_env(config) + + for var, value in os.environ.items(): + assert var in result_env + assert result_env[var] == value + + def test_includes_scm_info(self): + """The output includes SCM information.""" + config, _, _ = get_config_data({"current_version": "0.1.0"}) + result_env = base_env(config) + + assert f"{PREFIX}COMMIT_SHA" in result_env + assert f"{PREFIX}DISTANCE_TO_LATEST_TAG" in result_env + assert f"{PREFIX}IS_DIRTY" in result_env + assert f"{PREFIX}BRANCH_NAME" in result_env + assert f"{PREFIX}SHORT_BRANCH_NAME" in result_env + assert f"{PREFIX}CURRENT_VERSION" in result_env + assert f"{PREFIX}CURRENT_TAG" in result_env + + +def test_current_version_env_includes_correct_info(): + """pass""" + config, _, current_version = get_config_data( + {"current_version": "0.1.0", "parse": r"(?P\d+)\.(?P\d+)\.(?P\d+)"} + ) + result = version_env(current_version, "CURRENT_") + + assert result[f"{PREFIX}CURRENT_MAJOR"] == "0" + assert result[f"{PREFIX}CURRENT_MINOR"] == "1" + assert result[f"{PREFIX}CURRENT_PATCH"] == "0" diff --git a/tests/test_hooks/test_run_command.py b/tests/test_hooks/test_run_command.py new file mode 100644 index 00000000..58f60cd0 --- /dev/null +++ b/tests/test_hooks/test_run_command.py @@ -0,0 +1,44 @@ +import subprocess +import sys + +import pytest + +from bumpversion.hooks import run_command + + +class TestRunCommand: + """Test the run_command function.""" + + def test_runs_a_str_command(self): + """Runs the command formatted as a string.""" + result = run_command("echo Hello") + assert isinstance(result, subprocess.CompletedProcess) + assert result.stdout == "Hello\n" + + def test_can_access_env(self): + """The command can access custom environment variables.""" + cmd = "echo %TEST_ENV%" if sys.platform == "win32" else "echo $TEST_ENV" + result = run_command(cmd, environment={"TEST_ENV": "Hello"}) + assert isinstance(result, subprocess.CompletedProcess) + assert result.stdout == "Hello\n" + + def test_non_zero_exit(self): + """The result shows a non-zero result code.""" + result = run_command("exit 1") + assert result.returncode == 1 + + @pytest.mark.parametrize( + "invalid_script", + [(123,), (None,), (["exit", "1"])], + ) + def test_an_invalid_script_raises_type_error(self, invalid_script): + with pytest.raises(TypeError): + run_command(invalid_script) + + @pytest.mark.parametrize( + "invalid_env", + [("string",), (123,), (None,)], + ) + def test_an_invalid_env_raises_type_error(self, invalid_env): + with pytest.raises(TypeError): + run_command("echo Hello", environment=invalid_env) diff --git a/tests/test_hooks/test_run_hook_suites.py b/tests/test_hooks/test_run_hook_suites.py new file mode 100644 index 00000000..17667edf --- /dev/null +++ b/tests/test_hooks/test_run_hook_suites.py @@ -0,0 +1,118 @@ +import subprocess +from typing import Callable + +import pytest +from pytest import param +from bumpversion.hooks import run_setup_hooks, run_pre_commit_hooks, run_post_commit_hooks +from tests.conftest import get_config_data, get_semver + + +def get_hook_env(*args, **kwargs) -> dict: + """Mocked function for the environment setup""" + return {} + + +def run_command(script: str, env: dict) -> subprocess.CompletedProcess: + """Mocked function for command execution""" + return subprocess.CompletedProcess(args=script, returncode=0) + + +CURRENT_VERSION = get_semver("1.0.0") +NEW_VERSION = get_semver("1.1.0") + + +class TestHookSuites: + """Run each hook suite through the same set of tests.""" + + suites = ( + param("setup", run_setup_hooks, (CURRENT_VERSION,), id="setup"), + param( + "pre_commit", + run_pre_commit_hooks, + ( + CURRENT_VERSION, + NEW_VERSION, + ), + id="pre_commit", + ), + param( + "post_commit", + run_post_commit_hooks, + ( + CURRENT_VERSION, + NEW_VERSION, + ), + id="post_commit", + ), + ) + + @pytest.mark.parametrize(["suite_name", "suite_func", "suite_args"], suites) + def test_calls_each_hook(self, mocker, suite_name: str, suite_func: Callable, suite_args: tuple): + """The suite hook runs each hook.""" + # Assemble + env = {"var": "value"} + mock_env = mocker.patch(f"bumpversion.hooks.get_{suite_name}_hook_env") + mock_env.return_value = env + mock_run_command = mocker.patch("bumpversion.hooks.run_command") + mock_run_command.return_value = mocker.MagicMock(stdout="output", stderr="error", returncode=0) + mock_logger = mocker.patch("bumpversion.hooks.logger") + + config, _, _ = get_config_data({"current_version": "1.0.0", f"{suite_name}_hooks": ["script1", "script2"]}) + + # Act + suite_func(config, *suite_args) + + # Assert + mock_logger.info.assert_called_once_with(f"Running {suite_name} hooks:".replace("_", "-")) + mock_env.assert_called_once_with(config, *suite_args) + expected_run_command_calls = [ + mocker.call("script1", env), + mocker.call("script2", env), + ] + mock_run_command.assert_has_calls(expected_run_command_calls) + + @pytest.mark.parametrize(["suite_name", "suite_func", "suite_args"], suites) + def test_does_not_run_hooks_if_none_are_specified( + self, mocker, suite_name: str, suite_func: Callable, suite_args: tuple + ): + """If no setup_hooks are defined, nothing is run.""" + # Assemble + env = {"var": "value"} + mock_env = mocker.patch(f"bumpversion.hooks.get_{suite_name}_hook_env") + mock_env.return_value = env + mock_run_command = mocker.patch("bumpversion.hooks.run_command") + mock_run_command.return_value = mocker.MagicMock(stdout="output", stderr="error", returncode=0) + mock_logger = mocker.patch("bumpversion.hooks.logger") + + config, _, _ = get_config_data({"current_version": "1.0.0", f"{suite_name}_hooks": []}) + + # Act + suite_func(config, *suite_args) + + # Asserts + mock_logger.info.assert_called_once_with(f"No {suite_name} hooks defined".replace("_", "-")) + mock_env.assert_called_once_with(config, *suite_args) + assert mock_run_command.call_count == 0 + + @pytest.mark.parametrize(["suite_name", "suite_func", "suite_args"], suites) + def test_does_not_run_hooks_if_dry_run_is_true( + self, mocker, suite_name: str, suite_func: Callable, suite_args: tuple + ): + """If dry_run is True, nothing is run.""" + # Assemble + env = {"var": "value"} + mock_env = mocker.patch(f"bumpversion.hooks.get_{suite_name}_hook_env") + mock_env.return_value = env + mock_run_command = mocker.patch("bumpversion.hooks.run_hooks") + mock_logger = mocker.patch("bumpversion.hooks.logger") + + config, _, _ = get_config_data({"current_version": "1.0.0", f"{suite_name}_hooks": ["script1", "script2"]}) + + # Act + args = [*suite_args, True] + suite_func(config, *args) + + # Asserts + mock_logger.info.assert_called_once_with(f"Would run {suite_name} hooks:".replace("_", "-")) + mock_env.assert_called_once_with(config, *suite_args) + assert mock_run_command.call_count == 1 diff --git a/tests/test_hooks/test_run_hooks.py b/tests/test_hooks/test_run_hooks.py new file mode 100644 index 00000000..f7538924 --- /dev/null +++ b/tests/test_hooks/test_run_hooks.py @@ -0,0 +1,48 @@ +"""Tests for the run_hooks function.""" + +from bumpversion import hooks + + +def test_calls_each_hook(mocker): + """It should call each hook passed to it.""" + # Assemble + mock_logger = mocker.patch("bumpversion.hooks.logger") + mock_run_command = mocker.patch("bumpversion.hooks.run_command") + hooks_list = ["script1", "script2"] + env = {"var": "value"} + mock_run_command.return_value = mocker.MagicMock(stdout="output", stderr="error", returncode=0) + + # Act + hooks.run_hooks(hooks_list, env) + + # Assert + expected_calls = [ + mocker.call("Running 'script1'"), + mocker.call("output"), + mocker.call("error"), + mocker.call("Exited with 0"), + mocker.call("Running 'script2'"), + mocker.call("output"), + mocker.call("error"), + mocker.call("Exited with 0"), + ] + mock_logger.debug.assert_has_calls(expected_calls) + mock_run_command.assert_any_call("script1", env) + mock_run_command.assert_any_call("script2", env) + + +def test_does_not_call_each_hook_when_dry_run(mocker): + """It should not call each hook passed to it when dry_run is True.""" + # Assemble + mock_logger = mocker.patch("bumpversion.hooks.logger") + mock_run_command = mocker.patch("bumpversion.hooks.run_command") + hooks_list = ["script1", "script2"] + env = {"var": "value"} + + # Act + hooks.run_hooks(hooks_list, env, dry_run=True) + + # Assert + expected_calls = [mocker.call("Would run 'script1'"), mocker.call("Would run 'script2'")] + mock_logger.debug.assert_has_calls(expected_calls) + mock_run_command.assert_not_called() diff --git a/tests/test_scm.py b/tests/test_scm.py index d13c72c7..ed0a0af3 100644 --- a/tests/test_scm.py +++ b/tests/test_scm.py @@ -135,37 +135,13 @@ def test_returns_commit_and_tag_info(self, git_repo: Path) -> None: tag_info = scm.Git.latest_tag_info(tag_name, parse_pattern=parse_pattern) assert tag_info.commit_sha is not None assert tag_info.current_version == "0.1.0" + assert tag_info.current_tag == f"{tag_prefix}0.1.0" assert tag_info.distance_to_latest_tag == 0 assert tag_info.branch_name == "master" assert tag_info.short_branch_name == "master" assert tag_info.repository_root == git_repo assert tag_info.dirty is False - def test_git_latest_tag_info(self, git_repo: Path) -> None: - """Should return information about the latest tag.""" - readme = git_repo.joinpath("readme.md") - readme.touch() - tag_prefix = "app/" - parse_pattern = r"(?P\d+)\.(?P\d+)\.(?P\d+)" - tag_name = f"{tag_prefix}{{new_version}}" - with inside_dir(git_repo): - # Add a file and tag - subprocess.run(["git", "add", "readme.md"]) - subprocess.run(["git", "commit", "-m", "first"]) - subprocess.run(["git", "tag", f"{tag_prefix}0.1.0"]) - - # Make it dirty - git_repo.joinpath("something.md").touch() - subprocess.run(["git", "add", "something.md"]) - tag_info = scm.Git.latest_tag_info(tag_name, parse_pattern=parse_pattern) - assert tag_info.commit_sha is not None - assert tag_info.current_version == "0.1.0" - assert tag_info.distance_to_latest_tag == 0 - assert tag_info.branch_name == "master" - assert tag_info.short_branch_name == "master" - assert tag_info.repository_root == git_repo - assert tag_info.dirty is True - def test_git_detects_existing_tag(git_repo: Path, caplog: LogCaptureFixture) -> None: """Attempting to tag when a tag exists will do nothing."""