Coverage for src/rhiza_tools/commands/bump/__init__.py: 100%

93 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-30 13:37 +0000

1"""Command to bump version using semver and bump-my-version. 

2 

3This module implements version bumping functionality with support for semantic 

4versioning, interactive selection, and various bump types (patch, minor, major, 

5prerelease variants). Supports multiple languages including Python and Go. 

6 

7Example: 

8 Bump to a specific version:: 

9 

10 from rhiza_tools.commands.bump import bump_command 

11 bump_command("1.2.3") 

12 

13 Bump patch version with commit:: 

14 

15 bump_command("patch", commit=True) 

16 

17 Interactive bump (no version specified):: 

18 

19 bump_command(None) 

20""" 

21 

22import semver 

23import typer 

24 

25from rhiza_tools import console 

26from rhiza_tools.commands._shared import ( 

27 get_current_git_branch, 

28 get_latest_remote_version, 

29) 

30 

31# Re-export the bump-my-version adapter helpers for the same reason. Names called 

32# directly inside this module (bump_command) need no alias; pure re-exports use one. 

33from rhiza_tools.commands.bump.engine import ( 

34 BumpConfig, 

35 _build_changelog_hooks, 

36 _build_configuration, 

37 _execute_bump, 

38 _preflight_bump, 

39 _preview_file_modifications, 

40) 

41from rhiza_tools.commands.bump.engine import ( 

42 _show_file_changes as _show_file_changes, 

43) 

44 

45# Git helpers (branch checkout/restore, push to remote) live in bump/git.py; re-exported 

46# here so callers and tests that use ``rhiza_tools.commands.bump.<helper>`` keep working. 

47from rhiza_tools.commands.bump.git import ( 

48 _handle_branch_checkout as _handle_branch_checkout, 

49) 

50from rhiza_tools.commands.bump.git import ( 

51 _handle_push_to_remote as _handle_push_to_remote, 

52) 

53from rhiza_tools.commands.bump.git import ( 

54 _restore_original_branch as _restore_original_branch, 

55) 

56from rhiza_tools.commands.bump.io import ( 

57 SUPPORTED_LANGUAGES_MSG as SUPPORTED_LANGUAGES_MSG, 

58) 

59 

60# Project I/O, interactive UI, and public data model live in bump/io.py; re-exported 

61# here so callers and tests that use ``rhiza_tools.commands.bump.<name>`` keep working. 

62from rhiza_tools.commands.bump.io import ( 

63 BumpOptions as BumpOptions, 

64) 

65from rhiza_tools.commands.bump.io import ( 

66 Language as Language, 

67) 

68from rhiza_tools.commands.bump.io import ( 

69 _log_bump_success as _log_bump_success, 

70) 

71from rhiza_tools.commands.bump.io import ( 

72 _show_interactive_preview as _show_interactive_preview, 

73) 

74from rhiza_tools.commands.bump.io import ( 

75 _validate_project_exists as _validate_project_exists, 

76) 

77from rhiza_tools.commands.bump.io import ( 

78 get_current_version as get_current_version, 

79) 

80from rhiza_tools.commands.bump.io import ( 

81 get_interactive_bump_type as get_interactive_bump_type, 

82) 

83from rhiza_tools.commands.bump.io import ( 

84 parse_language_option as parse_language_option, 

85) 

86 

87# Re-export pure version-math helpers so external callers and tests that reference 

88# ``rhiza_tools.commands.bump.<name>`` (including monkeypatch string paths) keep 

89# working after these moved to bump/versioning.py. The redundant ``as`` aliases mark 

90# them as intentional re-exports for ruff (F401). 

91from rhiza_tools.commands.bump.versioning import ( 

92 _CHOICE_PREFIX_TO_BUMP_TYPE as _CHOICE_PREFIX_TO_BUMP_TYPE, 

93) 

94from rhiza_tools.commands.bump.versioning import ( 

95 _VALID_BUMP_TYPES as _VALID_BUMP_TYPES, 

96) 

97from rhiza_tools.commands.bump.versioning import ( 

98 _denormalize_pep440_to_semver as _denormalize_pep440_to_semver, 

99) 

100from rhiza_tools.commands.bump.versioning import ( 

101 _determine_bump_type_from_choice as _determine_bump_type_from_choice, 

102) 

103from rhiza_tools.commands.bump.versioning import ( 

104 _parse_version_argument, 

105) 

106from rhiza_tools.commands.bump.versioning import ( 

107 _validate_explicit_version as _validate_explicit_version, 

108) 

109from rhiza_tools.commands.bump.versioning import ( 

110 get_bumped_version_from_type as get_bumped_version_from_type, 

111) 

112from rhiza_tools.commands.bump.versioning import ( 

113 get_next_prerelease as get_next_prerelease, 

114) 

115 

116 

117def _resolve_bump_baseline(current_version_str: str) -> str: 

118 """Return the version to bump *from*, never lower than the latest remote tag. 

119 

120 The local ``pyproject.toml`` can be stale (a branch that diverged before the 

121 previous release was merged), which historically caused relative bumps to 

122 generate already-released version numbers (issue #1126). When the highest 

123 semver tag on the remote is newer than the local version, that remote 

124 version is used as the baseline instead and the discrepancy is reported. 

125 

126 The remote is queried best-effort: if it cannot be reached (offline, no 

127 remote configured) the local version is used unchanged. 

128 

129 Args: 

130 current_version_str: The version read from the local project files. 

131 

132 Returns: 

133 The version string to use as the basis for relative bumps. 

134 """ 

135 latest_remote = get_latest_remote_version() 

136 if latest_remote is None: 

137 return current_version_str 

138 

139 try: 

140 local_version = semver.Version.parse(current_version_str) 

141 except ValueError: 

142 local_version = None 

143 

144 if local_version is None or latest_remote > local_version: 

145 console.warning( 

146 f"Local version {current_version_str} is behind the latest remote tag v{latest_remote}; " 

147 f"bumping from v{latest_remote} instead to avoid releasing an older version." 

148 ) 

149 return str(latest_remote) 

150 

151 return current_version_str 

152 

153 

154def _resolve_language(options: BumpOptions) -> Language: 

155 """Resolve the project language from options, auto-detecting when unset. 

156 

157 Args: 

158 options: Configuration options for the bump command. 

159 

160 Returns: 

161 The resolved programming language. 

162 

163 Raises: 

164 typer.Exit: If no language is given and none can be detected. 

165 """ 

166 if options.language is None: 

167 detected_language = Language.detect() 

168 if detected_language is None: 

169 console.error("Unable to detect project language.") 

170 console.error("Please specify language explicitly with --language option.") 

171 console.error(SUPPORTED_LANGUAGES_MSG) 

172 raise typer.Exit(code=1) 

173 language = detected_language 

174 console.info(f"Detected language: {typer.style(language.value, fg=typer.colors.CYAN, bold=True)}") 

175 else: 

176 language = options.language 

177 console.info(f"Using language: {typer.style(language.value, fg=typer.colors.CYAN, bold=True)}") 

178 return language 

179 

180 

181def _finalize_bump( 

182 options: BumpOptions, 

183 current_version_str: str, 

184 config: BumpConfig, 

185 language: Language, 

186 commit: bool, 

187 push: bool, 

188) -> None: 

189 """Report the outcome of a bump and push to remote when requested. 

190 

191 In dry-run mode this only logs what would have happened; otherwise it logs 

192 the successful bump and, if ``push`` is set, pushes the changes to the remote. 

193 

194 Args: 

195 options: Configuration options for the bump command. 

196 current_version_str: The original version string before the bump. 

197 config: The bumpversion configuration object. 

198 language: The programming language (python or go). 

199 commit: Whether the bump committed the change. 

200 push: Whether to push the change to the remote. 

201 """ 

202 if options.dry_run: 

203 console.info("[DRY-RUN] Bump completed (no changes made)") 

204 if commit: 

205 console.info("[DRY-RUN] Would commit the changes") 

206 if push: 

207 console.info("[DRY-RUN] Would push changes to remote") 

208 else: 

209 _log_bump_success(current_version_str, config, language) 

210 

211 # Handle push 

212 if push: 

213 _handle_push_to_remote(options.version) 

214 

215 

216def bump_command(options: BumpOptions) -> None: 

217 """Bump version using bump-my-version. 

218 

219 This function handles the complete version bumping workflow including 

220 configuration loading, version parsing, interactive selection (if needed), 

221 and executing the bump operation. 

222 

223 Supports multiple languages: 

224 - Python: uses pyproject.toml 

225 - Go: uses VERSION file with go.mod 

226 - Other: uses VERSION file 

227 

228 Args: 

229 options: Configuration options for the bump command. 

230 

231 Raises: 

232 typer.Exit: If project files are missing, configuration is invalid, or 

233 bump operation fails. 

234 

235 Example: 

236 Bump to patch version:: 

237 

238 bump_command(BumpOptions(version="patch")) 

239 

240 Bump with dry run:: 

241 

242 bump_command(BumpOptions(version="1.2.3", dry_run=True)) 

243 

244 Interactive bump with commit:: 

245 

246 bump_command(BumpOptions(commit=True)) 

247 

248 Bump and push to remote:: 

249 

250 bump_command(BumpOptions(version="minor", push=True)) 

251 """ 

252 # Detect or use provided language 

253 language = _resolve_language(options) 

254 

255 _validate_project_exists(language) 

256 

257 # Handle branch checkout if specified 

258 original_branch = _handle_branch_checkout(options.branch, options.dry_run) 

259 

260 # Determine commit/push settings 

261 # In non-interactive mode (version specified), flags control behaviour directly. 

262 # In interactive mode (no version), the user is prompted for each step. 

263 is_interactive = not options.version 

264 commit = options.commit or options.push 

265 push = options.push 

266 

267 current_version_str = get_current_version(language) 

268 config, config_path = _build_configuration(current_version_str, options.allow_dirty, commit, options.config) 

269 

270 # Get current branch for display 

271 current_git_branch = get_current_git_branch() 

272 

273 console.info(f"Current branch: {typer.style(current_git_branch, fg=typer.colors.CYAN, bold=True)}") 

274 console.info(f"Current version: {typer.style(current_version_str, fg=typer.colors.CYAN, bold=True)}") 

275 

276 # Reconcile the bump baseline with the remote so a stale local pyproject.toml 

277 # (e.g. a branch that diverged before the previous release merged) cannot 

278 # produce a version lower than what is already published (issue #1126). 

279 # Explicit target versions are honoured as-is; only relative bumps 

280 # (patch/minor/major/prerelease) follow the remote-aware baseline. 

281 bump_baseline = _resolve_bump_baseline(current_version_str) 

282 

283 # Determine new version string 

284 if options.version: 

285 new_version_str = _parse_version_argument(options.version, bump_baseline) 

286 else: 

287 new_version_str = get_interactive_bump_type(bump_baseline) 

288 

289 console.info(f"New version will be: {typer.style(new_version_str, fg=typer.colors.GREEN, bold=True)}") 

290 

291 # Show preview of file changes 

292 _preview_file_modifications(config, current_version_str, new_version_str) 

293 

294 # Interactive preview and confirmation (only in true interactive mode) 

295 if is_interactive: 

296 proceed, commit, push = _show_interactive_preview( 

297 current_version_str, 

298 new_version_str, 

299 current_git_branch, 

300 ) 

301 if not proceed: 

302 console.info("Version bump cancelled by user") 

303 raise typer.Exit(code=0) 

304 # Rebuild configuration with the user's commit decision 

305 config, config_path = _build_configuration(current_version_str, options.allow_dirty, commit, options.config) 

306 

307 # Preflight: validate bump would succeed before making any changes 

308 if not options.dry_run: 

309 _preflight_bump(new_version_str, config, config_path) 

310 # Rebuild configuration to avoid stale state from dry-run 

311 config, config_path = _build_configuration(current_version_str, options.allow_dirty, commit, options.config) 

312 

313 # When we are about to create a real commit, fold a freshly generated 

314 # CHANGELOG.md into it via git-cliff (mirroring how uv.lock is included). This 

315 # keeps the changelog current as part of the bump commit, avoiding a separate 

316 # unreviewed push to the default branch. No-op for projects without a cliff.toml. 

317 if commit and not options.dry_run: 

318 config.pre_commit_hooks = list(config.pre_commit_hooks) + _build_changelog_hooks(new_version_str) 

319 

320 _execute_bump(new_version_str, config, config_path, options.dry_run) 

321 

322 _finalize_bump(options, current_version_str, config, language, commit, push) 

323 

324 # Restore original branch if we switched 

325 _restore_original_branch(original_branch, options.dry_run)