Coverage for src/rhiza_tools/commands/bump/engine.py: 100%
96 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-30 13:37 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-30 13:37 +0000
1"""Adapter isolating all bump-my-version integration for the bump command.
3This module is the single place in rhiza-tools that touches bump-my-version
4internals (``bumpversion.bump.do_bump``, ``bumpversion.config.get_configuration``,
5``bumpversion.ui.setup_logging`` and the shape of the configuration object). All
6other modules go through the helpers defined here, so an upstream change in
7bump-my-version only has to be reconciled in one location. The companion
8contract test in ``tests/test_bumpversion_contract.py`` pins the upstream
9interface this adapter relies on.
10"""
12from pathlib import Path
13from typing import TypeAlias
15import typer
16from bumpversion.bump import do_bump
17from bumpversion.config import get_configuration
18from bumpversion.config.models import Config
19from bumpversion.exceptions import BumpVersionError
20from bumpversion.ui import setup_logging
21from loguru import logger
23from rhiza_tools import console
24from rhiza_tools.config import CONFIG_FILENAME
26# ``BumpConfig`` is bump-my-version's concrete ``bumpversion.config.models.Config``.
27# It is used directly (rather than ``Any`` or a structural Protocol) because
28# ``do_bump`` requires this exact type, so the adapter must hold a real ``Config``
29# anyway. Importing it keeps every helper below fully typed under strict ``ty``.
30# The companion ``tests/test_bumpversion_contract.py`` pins the attributes used.
31#
32# Declared as an explicit ``TypeAlias`` (not ``import ... as BumpConfig``) so it
33# is (a) a re-export the split bump command modules can import under mypy
34# ``--strict``'s no-implicit-reexport rule, and (b) usable in annotations even
35# though bump-my-version is untyped (``Config`` resolves to ``Any``).
36BumpConfig: TypeAlias = Config
39def _build_configuration(
40 current_version_str: str,
41 allow_dirty: bool,
42 commit: bool,
43 config_path: Path | None = None,
44) -> tuple[BumpConfig, Path]:
45 """Build bumpversion configuration with appropriate overrides.
47 Args:
48 current_version_str: The current version string.
49 allow_dirty: If True, allow bumping even with uncommitted changes.
50 commit: If True, automatically commit the version change to git.
51 config_path: Path to the .cfg.toml config file. Defaults to CONFIG_FILENAME.
53 Returns:
54 A tuple of (config object, config_path).
56 Raises:
57 typer.Exit: If configuration loading fails.
58 """
59 if config_path is None:
60 config_path = Path(CONFIG_FILENAME)
61 overrides: dict[str, str | bool] = {"current_version": current_version_str}
62 if allow_dirty:
63 overrides["allow_dirty"] = True
64 if commit:
65 overrides["commit"] = True
67 try:
68 config = get_configuration(config_file=config_path, **overrides)
69 # Config loading fails in a few distinct ways: a malformed TOML file raises
70 # tomlkit's ``ParseError`` (a ``ValueError``), bump-my-version validation
71 # raises ``BumpVersionError``, and an unreadable file raises ``OSError``.
72 except (BumpVersionError, ValueError, OSError) as e:
73 console.error(f"Failed to load bumpversion configuration: {e}")
74 console.error(f"Check your bumpversion config at: {config_path}")
75 console.error("Ensure the [tool.bumpversion] section is valid TOML with correct version patterns.")
76 raise typer.Exit(code=1) from None
77 else:
78 return config, config_path
81def _build_changelog_hooks(new_version: str) -> list[str]:
82 """Build git-cliff pre-commit hooks that fold CHANGELOG.md into the bump commit.
84 bump-my-version commits whatever is staged when it runs its ``pre_commit_hooks`` —
85 the same mechanism the project config already uses to include ``uv.lock``.
86 Regenerating the changelog there means the version bump, the lockfile and the
87 changelog land in a single commit and tag, with no separate push to the default
88 branch. That separate push is undesirable: it is blocked by branch-protection
89 rulesets and counts as an unreviewed change against the OpenSSF Scorecard
90 Code-Review check.
92 The hooks are only emitted when the project is configured for git-cliff (a
93 ``cliff.toml`` is present), so projects without changelog tooling are unaffected.
94 The new version is passed with ``--tag`` because the release tag does not exist
95 yet when the hooks run; otherwise git-cliff would file the new entries under
96 "unreleased".
98 Args:
99 new_version: The version being bumped to (without a leading ``v``).
101 Returns:
102 The git-cliff hook commands, or an empty list when git-cliff is not configured.
104 Example:
105 >>> _build_changelog_hooks("1.2.3") # doctest: +SKIP
106 ['uvx git-cliff --tag v1.2.3 --output CHANGELOG.md', 'git add CHANGELOG.md']
107 """
108 if not (Path("cliff.toml").exists() or Path(".cliff.toml").exists()):
109 return []
110 return [
111 f"uvx git-cliff --tag v{new_version} --output CHANGELOG.md",
112 "git add CHANGELOG.md",
113 ]
116def _get_files_to_modify(config: BumpConfig) -> list[Path]:
117 """Get list of files that will be modified by bump-my-version.
119 Args:
120 config: The bumpversion configuration object.
122 Returns:
123 List of file paths that will be modified.
124 """
125 files = []
126 if hasattr(config, "files_to_modify"):
127 for file_config in config.files_to_modify:
128 # ``filename`` is typed ``str | None`` upstream; skip unnamed entries.
129 filename = file_config.filename
130 if filename:
131 files.append(Path(filename))
132 return files
135def _show_file_changes(file_path: Path, current_version: str, new_version: str) -> None:
136 """Show the changes that will be made to a file.
138 Args:
139 file_path: Path to the file to preview.
140 current_version: The current version string.
141 new_version: The new version string.
142 """
143 if not file_path.exists():
144 console.warning(f"File not found: {file_path}")
145 return
147 try:
148 content = file_path.read_text()
149 lines_with_version = []
151 for i, line in enumerate(content.split("\n"), 1):
152 if current_version in line:
153 lines_with_version.append((i, line))
155 if lines_with_version:
156 console.info(f" Changes in {typer.style(str(file_path), fg=typer.colors.CYAN, bold=True)}:")
157 for line_num, old_line in lines_with_version:
158 new_line = old_line.replace(current_version, new_version)
159 console.info(f" Line {line_num}:")
160 console.info(f" {typer.style('-', fg=typer.colors.RED)} {old_line.strip()}")
161 console.info(f" {typer.style('+', fg=typer.colors.GREEN)} {new_line.strip()}")
162 except OSError as e:
163 # Previewing is best-effort; an unreadable file should not abort the bump.
164 logger.debug(f"Could not preview changes for {file_path}: {e}")
167def _preview_file_modifications(config: BumpConfig, current_version: str, new_version: str) -> None:
168 """Preview what changes will be made to files.
170 Args:
171 config: The bumpversion configuration object.
172 current_version: The current version string.
173 new_version: The new version string.
174 """
175 files = _get_files_to_modify(config)
177 if files:
178 console.info(f"\n{typer.style('Files to be modified:', fg=typer.colors.YELLOW, bold=True)}")
179 for file_path in files:
180 _show_file_changes(file_path, current_version, new_version)
181 console.info("") # Empty line for spacing
182 else:
183 # Fallback: check common files
184 common_files = [Path("pyproject.toml"), Path("VERSION"), Path("setup.py"), Path("setup.cfg")]
185 console.info(f"\n{typer.style('Files to be modified:', fg=typer.colors.YELLOW, bold=True)}")
186 for file_path in common_files:
187 if file_path.exists():
188 _show_file_changes(file_path, current_version, new_version)
189 console.info("") # Empty line for spacing
192def _preflight_bump(new_version_str: str, config: BumpConfig, config_path: Path) -> None:
193 """Run a dry-run bump to validate the operation would succeed.
195 This preflight check ensures the bump operation will succeed before making
196 any actual changes. It catches configuration errors, file access issues,
197 and version format problems early, preventing partial failures that would
198 leave the repository in a state requiring manual recovery.
200 Args:
201 new_version_str: The new version string to validate.
202 config: The bumpversion configuration object.
203 config_path: Path to the bumpversion configuration file.
205 Raises:
206 typer.Exit: If the preflight validation fails.
207 """
208 console.info("Running preflight validation (dry-run)...")
209 setup_logging(verbose=1 if console.is_verbose() else 0)
211 try:
212 do_bump(
213 version_part=None,
214 new_version=new_version_str,
215 config=config,
216 config_file=config_path,
217 dry_run=True,
218 )
219 # do_bump surfaces version/format/hook problems as ``BumpVersionError`` and
220 # file-access problems as ``OSError``; both mean the bump cannot proceed.
221 except (BumpVersionError, OSError) as e:
222 console.error(f"Preflight validation failed: {e}")
223 console.error("No changes were made.")
224 raise typer.Exit(code=1) from None
226 console.success("Preflight validation passed")
229def _execute_bump(new_version_str: str, config: BumpConfig, config_path: Path, dry_run: bool) -> None:
230 """Execute the bump operation using bump-my-version.
232 Args:
233 new_version_str: The new version string to bump to.
234 config: The bumpversion configuration object.
235 config_path: Path to the bumpversion configuration file.
236 dry_run: If True, show what would change without actually changing anything.
238 Raises:
239 typer.Exit: If the bump operation fails.
240 """
241 console.info("Running bump-my-version...")
242 setup_logging(verbose=1 if console.is_verbose() else 0)
244 try:
245 do_bump(
246 version_part=None,
247 new_version=new_version_str,
248 config=config,
249 config_file=config_path,
250 dry_run=dry_run,
251 )
252 # do_bump surfaces version/format/hook problems as ``BumpVersionError`` and
253 # file-access problems as ``OSError``; both can leave files partially edited.
254 except (BumpVersionError, OSError) as e:
255 console.error(f"bump-my-version failed: {e}")
256 if not dry_run:
257 console.error("Files may have been partially modified. To recover:")
258 console.error(" 1. Check modified files: git diff")
259 console.error(" 2. Restore all changes: git checkout -- .")
260 console.error(" 3. Remove untracked: git clean -fd")
261 console.error("Or to keep changes, fix the issue and retry.")
262 raise typer.Exit(code=1) from None