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
« 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.
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.
7Example:
8 Bump to a specific version::
10 from rhiza_tools.commands.bump import bump_command
11 bump_command("1.2.3")
13 Bump patch version with commit::
15 bump_command("patch", commit=True)
17 Interactive bump (no version specified)::
19 bump_command(None)
20"""
22import semver
23import typer
25from rhiza_tools import console
26from rhiza_tools.commands._shared import (
27 get_current_git_branch,
28 get_latest_remote_version,
29)
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)
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)
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)
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)
117def _resolve_bump_baseline(current_version_str: str) -> str:
118 """Return the version to bump *from*, never lower than the latest remote tag.
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.
126 The remote is queried best-effort: if it cannot be reached (offline, no
127 remote configured) the local version is used unchanged.
129 Args:
130 current_version_str: The version read from the local project files.
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
139 try:
140 local_version = semver.Version.parse(current_version_str)
141 except ValueError:
142 local_version = None
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)
151 return current_version_str
154def _resolve_language(options: BumpOptions) -> Language:
155 """Resolve the project language from options, auto-detecting when unset.
157 Args:
158 options: Configuration options for the bump command.
160 Returns:
161 The resolved programming language.
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
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.
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.
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)
211 # Handle push
212 if push:
213 _handle_push_to_remote(options.version)
216def bump_command(options: BumpOptions) -> None:
217 """Bump version using bump-my-version.
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.
223 Supports multiple languages:
224 - Python: uses pyproject.toml
225 - Go: uses VERSION file with go.mod
226 - Other: uses VERSION file
228 Args:
229 options: Configuration options for the bump command.
231 Raises:
232 typer.Exit: If project files are missing, configuration is invalid, or
233 bump operation fails.
235 Example:
236 Bump to patch version::
238 bump_command(BumpOptions(version="patch"))
240 Bump with dry run::
242 bump_command(BumpOptions(version="1.2.3", dry_run=True))
244 Interactive bump with commit::
246 bump_command(BumpOptions(commit=True))
248 Bump and push to remote::
250 bump_command(BumpOptions(version="minor", push=True))
251 """
252 # Detect or use provided language
253 language = _resolve_language(options)
255 _validate_project_exists(language)
257 # Handle branch checkout if specified
258 original_branch = _handle_branch_checkout(options.branch, options.dry_run)
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
267 current_version_str = get_current_version(language)
268 config, config_path = _build_configuration(current_version_str, options.allow_dirty, commit, options.config)
270 # Get current branch for display
271 current_git_branch = get_current_git_branch()
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)}")
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)
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)
289 console.info(f"New version will be: {typer.style(new_version_str, fg=typer.colors.GREEN, bold=True)}")
291 # Show preview of file changes
292 _preview_file_modifications(config, current_version_str, new_version_str)
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)
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)
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)
320 _execute_bump(new_version_str, config, config_path, options.dry_run)
322 _finalize_bump(options, current_version_str, config, language, commit, push)
324 # Restore original branch if we switched
325 _restore_original_branch(original_branch, options.dry_run)