Skip to content

Commit 49f1953

Browse files
committed
Enhance hook handling and testing across hook types
- Introduced unified handling for setup, pre-commit, and post-commit hooks, including dry-run support. - Added comprehensive tests to ensure the correct behavior for all hook phases, including cases where no hooks are specified or in dry run mode. - Updated environment setup to use a common version environment function.
1 parent 3b638e0 commit 49f1953

File tree

6 files changed

+235
-45
lines changed

6 files changed

+235
-45
lines changed

bumpversion/bump.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def do_bump(
8383
version = config.version_config.parse(config.current_version)
8484
logger.dedent()
8585

86-
run_setup_hooks(config)
86+
run_setup_hooks(config, version, dry_run)
8787

8888
next_version = get_next_version(version, config, version_part, new_version)
8989
next_version_str = config.version_config.serialize(next_version, ctx)
@@ -116,11 +116,11 @@ def do_bump(
116116
ctx = get_context(config, version, next_version)
117117
ctx["new_version"] = next_version_str
118118

119-
run_pre_commit_hooks(config)
119+
run_pre_commit_hooks(config, version, next_version, dry_run)
120120

121121
commit_and_tag(config, config_file, configured_files, ctx, dry_run)
122122

123-
run_post_commit_hooks(config)
123+
run_post_commit_hooks(config, version, next_version, dry_run)
124124

125125
logger.info("Done.")
126126

bumpversion/hooks.py

+70-22
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
import datetime
44
import os
55
import subprocess
6-
from typing import Dict, Optional
6+
from typing import Dict, List, Optional
77

88
from bumpversion.config.models import Config
99
from bumpversion.ui import get_indented_logger
10+
from bumpversion.versioning.models import Version
1011

1112
PREFIX = "BVHOOK_"
1213

@@ -46,30 +47,43 @@ def scm_env(config: Config) -> Dict[str, str]:
4647
}
4748

4849

49-
def current_version_env(config: Config) -> Dict[str, str]:
50-
"""Provide the current version environment variables."""
51-
version_str = config.current_version
52-
version = config.version_config.parse(version_str)
50+
def version_env(version: Version, version_prefix: str) -> Dict[str, str]:
51+
"""Provide the environment variables for each version component with a prefix."""
52+
return {f"{PREFIX}{version_prefix}{part.upper()}": version[part].value for part in version}
5353

54-
return {f"{PREFIX}CURRENT_{part.upper()}": version[part].value for part in version}
5554

56-
57-
def setup_hook_env(config: Config) -> Dict[str, str]:
55+
def get_setup_hook_env(config: Config, current_version: Version) -> Dict[str, str]:
5856
"""Provide the environment dictionary for `setup_hook`s."""
59-
return {**base_env(config), **scm_env(config), **current_version_env(config)}
57+
return {**base_env(config), **scm_env(config), **version_env(current_version, "CURRENT_")}
6058

6159

62-
def run_setup_hooks(config: Config) -> None:
63-
"""Run the setup hooks."""
64-
env = setup_hook_env(config)
65-
if config.setup_hooks:
66-
logger.info("Running setup hooks:")
67-
else:
68-
logger.info("No setup hooks defined")
69-
return
60+
def get_pre_commit_hook_env(config: Config, current_version: Version, new_version: Version) -> Dict[str, str]:
61+
"""Provide the environment dictionary for `pre_commit_hook`s."""
62+
return {
63+
**base_env(config),
64+
**scm_env(config),
65+
**version_env(current_version, "CURRENT_"),
66+
**version_env(new_version, "NEW_"),
67+
}
68+
69+
70+
def get_post_commit_hook_env(config: Config, current_version: Version, new_version: Version) -> Dict[str, str]:
71+
"""Provide the environment dictionary for `post_commit_hook`s."""
72+
return {
73+
**base_env(config),
74+
**scm_env(config),
75+
**version_env(current_version, "CURRENT_"),
76+
**version_env(new_version, "NEW_"),
77+
}
78+
7079

80+
def run_hooks(hooks: List[str], env: Dict[str, str], dry_run: bool = False) -> None:
81+
"""Run a list of command-line programs using the shell."""
7182
logger.indent()
72-
for script in config.setup_hooks:
83+
for script in hooks:
84+
if dry_run:
85+
logger.debug(f"Would run {script!r}")
86+
continue
7387
logger.debug(f"Running {script!r}")
7488
logger.indent()
7589
result = run_command(script, env)
@@ -80,11 +94,45 @@ def run_setup_hooks(config: Config) -> None:
8094
logger.dedent()
8195

8296

83-
def run_pre_commit_hooks(config: Config) -> None:
97+
def run_setup_hooks(config: Config, current_version: Version, dry_run: bool = False) -> None:
98+
"""Run the setup hooks."""
99+
env = get_setup_hook_env(config, current_version)
100+
if config.setup_hooks:
101+
running = "Would run" if dry_run else "Running"
102+
logger.info(f"{running} setup hooks:")
103+
else:
104+
logger.info("No setup hooks defined")
105+
return
106+
107+
run_hooks(config.setup_hooks, env, dry_run)
108+
109+
110+
def run_pre_commit_hooks(
111+
config: Config, current_version: Version, new_version: Version, dry_run: bool = False
112+
) -> None:
84113
"""Run the pre-commit hooks."""
85-
pass
114+
env = get_pre_commit_hook_env(config, current_version, new_version)
115+
116+
if config.pre_commit_hooks:
117+
running = "Would run" if dry_run else "Running"
118+
logger.info(f"{running} pre-commit hooks:")
119+
else:
120+
logger.info("No pre-commit hooks defined")
121+
return
122+
123+
run_hooks(config.pre_commit_hooks, env, dry_run)
86124

87125

88-
def run_post_commit_hooks(config: Config) -> None:
126+
def run_post_commit_hooks(
127+
config: Config, current_version: Version, new_version: Version, dry_run: bool = False
128+
) -> None:
89129
"""Run the post-commit hooks."""
90-
pass
130+
env = get_post_commit_hook_env(config, current_version, new_version)
131+
if config.post_commit_hooks:
132+
running = "Would run" if dry_run else "Running"
133+
logger.info(f"{running} post-commit hooks:")
134+
else:
135+
logger.info("No post-commit hooks defined")
136+
return
137+
138+
run_hooks(config.post_commit_hooks, env, dry_run)

tests/conftest.py

+8
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
import pytest
1010

11+
from bumpversion.versioning.models import Version
12+
1113

1214
@pytest.fixture
1315
def tests_path() -> Path:
@@ -51,6 +53,12 @@ def get_config_data(overrides: dict) -> tuple:
5153
return conf, version_config, version
5254

5355

56+
def get_semver(version: str) -> Version:
57+
"""Get a semantic version from a string."""
58+
_, _, version = get_config_data({"current_version": version})
59+
return version
60+
61+
5462
@pytest.fixture
5563
def git_repo(tmp_path: Path) -> Path:
5664
"""Generate a simple temporary git repo and return the path."""

tests/test_hooks/test_envs.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import subprocess
66
from pathlib import Path
77

8-
from bumpversion.hooks import scm_env, PREFIX, base_env, current_version_env
8+
from bumpversion.hooks import scm_env, PREFIX, base_env, version_env
99
from tests.conftest import inside_dir, get_config_data
1010

1111

@@ -78,10 +78,10 @@ def test_includes_scm_info(self):
7878

7979
def test_current_version_env_includes_correct_info():
8080
"""pass"""
81-
config, _, _ = get_config_data(
81+
config, _, current_version = get_config_data(
8282
{"current_version": "0.1.0", "parse": r"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"}
8383
)
84-
result = current_version_env(config)
84+
result = version_env(current_version, "CURRENT_")
8585

8686
assert result[f"{PREFIX}CURRENT_MAJOR"] == "0"
8787
assert result[f"{PREFIX}CURRENT_MINOR"] == "1"

tests/test_hooks/test_run_hooks.py

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Tests for the run_hooks function."""
2+
3+
from bumpversion import hooks
4+
5+
6+
def test_calls_each_hook(mocker):
7+
"""It should call each hook passed to it."""
8+
# Assemble
9+
mock_logger = mocker.patch("bumpversion.hooks.logger")
10+
mock_run_command = mocker.patch("bumpversion.hooks.run_command")
11+
hooks_list = ["script1", "script2"]
12+
env = {"var": "value"}
13+
mock_run_command.return_value = mocker.MagicMock(stdout="output", stderr="error", returncode=0)
14+
15+
# Act
16+
hooks.run_hooks(hooks_list, env)
17+
18+
# Assert
19+
expected_calls = [
20+
mocker.call("Running 'script1'"),
21+
mocker.call("output"),
22+
mocker.call("error"),
23+
mocker.call("Exited with 0"),
24+
mocker.call("Running 'script2'"),
25+
mocker.call("output"),
26+
mocker.call("error"),
27+
mocker.call("Exited with 0"),
28+
]
29+
mock_logger.debug.assert_has_calls(expected_calls)
30+
mock_run_command.assert_any_call("script1", env)
31+
mock_run_command.assert_any_call("script2", env)
32+
33+
34+
def test_does_not_call_each_hook_when_dry_run(mocker):
35+
"""It should not call each hook passed to it when dry_run is True."""
36+
# Assemble
37+
mock_logger = mocker.patch("bumpversion.hooks.logger")
38+
mock_run_command = mocker.patch("bumpversion.hooks.run_command")
39+
hooks_list = ["script1", "script2"]
40+
env = {"var": "value"}
41+
42+
# Act
43+
hooks.run_hooks(hooks_list, env, dry_run=True)
44+
45+
# Assert
46+
expected_calls = [mocker.call("Would run 'script1'"), mocker.call("Would run 'script2'")]
47+
mock_logger.debug.assert_has_calls(expected_calls)
48+
mock_run_command.assert_not_called()
+103-17
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import subprocess
2+
from typing import Callable
23

3-
from bumpversion.config import Config
4-
from bumpversion.hooks import run_setup_hooks
5-
from tests.conftest import get_config_data
4+
import pytest
5+
from pytest import param
6+
from bumpversion.hooks import run_setup_hooks, run_pre_commit_hooks, run_post_commit_hooks
7+
from bumpversion.versioning.models import Version
8+
from tests.conftest import get_config_data, get_semver
69

710

8-
def setup_hook_env(config: Config) -> dict:
11+
def get_hook_env(*args, **kwargs) -> dict:
912
"""Mocked function for the environment setup"""
1013
return {}
1114

@@ -15,19 +18,102 @@ def run_command(script: str, env: dict) -> subprocess.CompletedProcess:
1518
return subprocess.CompletedProcess(args=script, returncode=0)
1619

1720

18-
def test_run_setup_hooks_calls_each_hook(mocker):
19-
"""The run_setup_hooks function runs each hook."""
20-
# Assemble
21-
setup_hook_env_mock = mocker.patch("bumpversion.hooks.setup_hook_env", side_effect=setup_hook_env)
22-
run_command_mock = mocker.patch("bumpversion.hooks.run_command", side_effect=run_command)
21+
CURRENT_VERSION = get_semver("1.0.0")
22+
NEW_VERSION = get_semver("1.1.0")
2323

24-
config, _, _ = get_config_data({"current_version": "0.1.0", "setup_hooks": ["script1", "script2"]})
2524

26-
# Act
27-
result = run_setup_hooks(config)
25+
class TestHookSuites:
26+
"""Run each hook suite through the same set of tests."""
2827

29-
# Asserts for function's behavior
30-
setup_hook_env_mock.assert_called_once_with(config)
31-
assert run_command_mock.call_count == len(config.setup_hooks)
32-
run_command_mock.assert_any_call("script1", {})
33-
run_command_mock.assert_any_call("script2", {})
28+
suites = (
29+
param("setup", run_setup_hooks, (CURRENT_VERSION,), id="setup"),
30+
param(
31+
"pre_commit",
32+
run_pre_commit_hooks,
33+
(
34+
CURRENT_VERSION,
35+
NEW_VERSION,
36+
),
37+
id="pre_commit",
38+
),
39+
param(
40+
"post_commit",
41+
run_post_commit_hooks,
42+
(
43+
CURRENT_VERSION,
44+
NEW_VERSION,
45+
),
46+
id="post_commit",
47+
),
48+
)
49+
50+
@pytest.mark.parametrize(["suite_name", "suite_func", "suite_args"], suites)
51+
def test_calls_each_hook(self, mocker, suite_name: str, suite_func: Callable, suite_args: tuple):
52+
"""The suite hook runs each hook."""
53+
# Assemble
54+
env = {"var": "value"}
55+
mock_env = mocker.patch(f"bumpversion.hooks.get_{suite_name}_hook_env")
56+
mock_env.return_value = env
57+
mock_run_command = mocker.patch("bumpversion.hooks.run_command")
58+
mock_run_command.return_value = mocker.MagicMock(stdout="output", stderr="error", returncode=0)
59+
mock_logger = mocker.patch("bumpversion.hooks.logger")
60+
61+
config, _, _ = get_config_data({"current_version": "1.0.0", f"{suite_name}_hooks": ["script1", "script2"]})
62+
63+
# Act
64+
suite_func(config, *suite_args)
65+
66+
# Assert
67+
mock_logger.info.assert_called_once_with(f"Running {suite_name} hooks:".replace("_", "-"))
68+
mock_env.assert_called_once_with(config, *suite_args)
69+
expected_run_command_calls = [
70+
mocker.call("script1", env),
71+
mocker.call("script2", env),
72+
]
73+
mock_run_command.assert_has_calls(expected_run_command_calls)
74+
75+
@pytest.mark.parametrize(["suite_name", "suite_func", "suite_args"], suites)
76+
def test_does_not_run_hooks_if_none_are_specified(
77+
self, mocker, suite_name: str, suite_func: Callable, suite_args: tuple
78+
):
79+
"""If no setup_hooks are defined, nothing is run."""
80+
# Assemble
81+
env = {"var": "value"}
82+
mock_env = mocker.patch(f"bumpversion.hooks.get_{suite_name}_hook_env")
83+
mock_env.return_value = env
84+
mock_run_command = mocker.patch("bumpversion.hooks.run_command")
85+
mock_run_command.return_value = mocker.MagicMock(stdout="output", stderr="error", returncode=0)
86+
mock_logger = mocker.patch("bumpversion.hooks.logger")
87+
88+
config, _, _ = get_config_data({"current_version": "1.0.0", f"{suite_name}_hooks": []})
89+
90+
# Act
91+
suite_func(config, *suite_args)
92+
93+
# Asserts
94+
mock_logger.info.assert_called_once_with(f"No {suite_name} hooks defined".replace("_", "-"))
95+
mock_env.assert_called_once_with(config, *suite_args)
96+
assert mock_run_command.call_count == 0
97+
98+
@pytest.mark.parametrize(["suite_name", "suite_func", "suite_args"], suites)
99+
def test_does_not_run_hooks_if_dry_run_is_true(
100+
self, mocker, suite_name: str, suite_func: Callable, suite_args: tuple
101+
):
102+
"""If dry_run is True, nothing is run."""
103+
# Assemble
104+
env = {"var": "value"}
105+
mock_env = mocker.patch(f"bumpversion.hooks.get_{suite_name}_hook_env")
106+
mock_env.return_value = env
107+
mock_run_command = mocker.patch("bumpversion.hooks.run_hooks")
108+
mock_logger = mocker.patch("bumpversion.hooks.logger")
109+
110+
config, _, _ = get_config_data({"current_version": "1.0.0", f"{suite_name}_hooks": ["script1", "script2"]})
111+
112+
# Act
113+
args = [*suite_args, True]
114+
suite_func(config, *args)
115+
116+
# Asserts
117+
mock_logger.info.assert_called_once_with(f"Would run {suite_name} hooks:".replace("_", "-"))
118+
mock_env.assert_called_once_with(config, *suite_args)
119+
assert mock_run_command.call_count == 1

0 commit comments

Comments
 (0)