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

1"""Adapter isolating all bump-my-version integration for the bump command. 

2 

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""" 

11 

12from pathlib import Path 

13from typing import TypeAlias 

14 

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 

22 

23from rhiza_tools import console 

24from rhiza_tools.config import CONFIG_FILENAME 

25 

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 

37 

38 

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. 

46 

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. 

52 

53 Returns: 

54 A tuple of (config object, config_path). 

55 

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 

66 

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 

79 

80 

81def _build_changelog_hooks(new_version: str) -> list[str]: 

82 """Build git-cliff pre-commit hooks that fold CHANGELOG.md into the bump commit. 

83 

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. 

91 

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". 

97 

98 Args: 

99 new_version: The version being bumped to (without a leading ``v``). 

100 

101 Returns: 

102 The git-cliff hook commands, or an empty list when git-cliff is not configured. 

103 

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 ] 

114 

115 

116def _get_files_to_modify(config: BumpConfig) -> list[Path]: 

117 """Get list of files that will be modified by bump-my-version. 

118 

119 Args: 

120 config: The bumpversion configuration object. 

121 

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 

133 

134 

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. 

137 

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 

146 

147 try: 

148 content = file_path.read_text() 

149 lines_with_version = [] 

150 

151 for i, line in enumerate(content.split("\n"), 1): 

152 if current_version in line: 

153 lines_with_version.append((i, line)) 

154 

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}") 

165 

166 

167def _preview_file_modifications(config: BumpConfig, current_version: str, new_version: str) -> None: 

168 """Preview what changes will be made to files. 

169 

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) 

176 

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 

190 

191 

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. 

194 

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. 

199 

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. 

204 

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) 

210 

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 

225 

226 console.success("Preflight validation passed") 

227 

228 

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. 

231 

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. 

237 

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) 

243 

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