Coverage for src / rhiza_tools / commands / bump.py: 89%
114 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 10:07 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 10:07 +0000
1"""Command to bump version in pyproject.toml using semver and bump-my-version."""
3from pathlib import Path
5import questionary as qs
6import semver
7import tomlkit
8import typer
9from bumpversion.bump import do_bump
10from bumpversion.config import get_configuration
11from bumpversion.ui import setup_logging
12from loguru import logger
14from rhiza_tools.config import CONFIG_FILENAME
16_COOL_STYLE = qs.Style(
17 [
18 ("separator", "fg:#cc5454"),
19 ("qmark", "fg:#2FA4A9 bold"),
20 ("question", ""),
21 ("selected", "fg:#2FA4A9 bold"),
22 ("pointer", "fg:#2FA4A9 bold"),
23 ("highlighted", "fg:#2FA4A9 bold"),
24 ("answer", "fg:#2FA4A9 bold"),
25 ("text", "fg:#ffffff"),
26 ("disabled", "fg:#858585 italic"),
27 ]
28)
30# Valid bump type keywords
31_VALID_BUMP_TYPES = ["patch", "minor", "major", "prerelease", "build", "alpha", "beta", "rc", "dev"]
33# Mapping of choice prefix to bump type for interactive selection
34_CHOICE_PREFIX_TO_BUMP_TYPE = {
35 "Patch": "patch",
36 "Minor": "minor",
37 "Major": "major",
38 "Alpha": "alpha",
39 "Beta": "beta",
40 "RC": "rc",
41 "Dev": "dev",
42 "Prerelease": "prerelease",
43 "Build": "build",
44}
47def get_current_version() -> str:
48 """Read current version from pyproject.toml."""
49 try:
50 with open("pyproject.toml") as f:
51 data = tomlkit.parse(f.read())
52 return data["project"]["version"]
53 except Exception as e:
54 logger.error(f"Failed to read version from pyproject.toml: {e}")
55 raise typer.Exit(code=1)
58def get_next_prerelease(current_version: semver.Version, token: str) -> semver.Version:
59 """Calculate next prerelease version for a given token."""
60 if current_version.prerelease:
61 if current_version.prerelease.startswith(token):
62 return current_version.bump_prerelease()
63 else:
64 return current_version.replace(prerelease=f"{token}.1")
65 else:
66 return current_version.bump_patch().bump_prerelease(token=token)
69def _determine_bump_type_from_choice(choice: str) -> str:
70 """Extract bump type from interactive choice string."""
71 for prefix, bump_type in _CHOICE_PREFIX_TO_BUMP_TYPE.items():
72 if choice.startswith(prefix):
73 return bump_type
74 return ""
77def _get_interactive_bump_type(config) -> str:
78 """Get bump type from user through interactive prompt."""
79 current_version_str = config.current_version
80 try:
81 current_version = semver.Version.parse(current_version_str)
82 except ValueError:
83 logger.error(f"Invalid semantic version in configuration: {current_version_str}")
84 raise typer.Exit(code=1)
86 next_patch = current_version.bump_patch()
87 next_minor = current_version.bump_minor()
88 next_major = current_version.bump_major()
89 next_prerelease = current_version.bump_prerelease()
90 next_build = current_version.bump_build()
92 next_alpha = get_next_prerelease(current_version, "alpha")
93 next_beta = get_next_prerelease(current_version, "beta")
94 next_rc = get_next_prerelease(current_version, "rc")
95 next_dev = get_next_prerelease(current_version, "dev")
97 choice = qs.select(
98 f"Select bump type (Current: {current_version_str})",
99 choices=[
100 f"Patch ({current_version_str} -> {next_patch})",
101 f"Minor ({current_version_str} -> {next_minor})",
102 f"Major ({current_version_str} -> {next_major})",
103 qs.Separator("-" * 30),
104 f"Prerelease ({current_version_str} -> {next_prerelease})",
105 f"Alpha ({current_version_str} -> {next_alpha})",
106 f"Beta ({current_version_str} -> {next_beta})",
107 f"RC ({current_version_str} -> {next_rc})",
108 f"Dev ({current_version_str} -> {next_dev})",
109 f"Build ({current_version_str} -> {next_build})",
110 ],
111 style=_COOL_STYLE,
112 ).ask()
114 if not choice:
115 raise typer.Exit(code=0)
117 # Extract the new version string from the choice
118 # Format is "Label (Current -> New)"
119 # We want "New"
120 new_version = choice.split("-> ")[1].rstrip(")")
121 return new_version
124def _parse_version_argument(version: str | None, current_version_str: str) -> str:
125 """Parse version argument and return explicit version string.
127 Args:
128 version: The version argument provided by the user.
129 current_version_str: The current version string.
131 Returns:
132 The explicit version string to bump to.
133 """
134 if not version:
135 return ""
137 try:
138 current_version = semver.Version.parse(current_version_str)
139 except ValueError:
140 logger.error(f"Invalid semantic version: {current_version_str}")
141 raise typer.Exit(code=1)
143 # Check if it's a bump type keyword
144 if version == "patch":
145 return str(current_version.bump_patch())
146 elif version == "minor":
147 return str(current_version.bump_minor())
148 elif version == "major":
149 return str(current_version.bump_major())
150 elif version == "prerelease":
151 return str(current_version.bump_prerelease())
152 elif version == "build":
153 return str(current_version.bump_build())
154 elif version in ["alpha", "beta", "rc", "dev"]:
155 return str(get_next_prerelease(current_version, version))
157 # Otherwise, it's an explicit version
158 # Strip 'v' prefix
159 if version.startswith("v"):
160 version = version[1:]
162 # Validate explicit version
163 try:
164 semver.Version.parse(version)
165 except ValueError:
166 logger.error(f"Invalid version format: {version}")
167 logger.error("Please use a valid semantic version.")
168 raise typer.Exit(code=1)
170 return version
173def bump_command(
174 version: str | None = None,
175 dry_run: bool = False,
176 commit: bool = False,
177 allow_dirty: bool = False,
178 verbose: bool = False,
179):
180 """Bump version in pyproject.toml using bump-my-version."""
181 # Check if pyproject.toml exists
182 if not Path("pyproject.toml").exists():
183 logger.error("pyproject.toml not found in current directory")
184 raise typer.Exit(code=1)
186 # Get current version from pyproject.toml
187 current_version_str = get_current_version()
189 # Construct configuration
190 config_path = Path(CONFIG_FILENAME)
191 overrides = {"current_version": current_version_str}
192 if allow_dirty:
193 overrides["allow_dirty"] = True
194 if commit:
195 overrides["commit"] = True
197 try:
198 config = get_configuration(config_file=config_path, **overrides)
199 except Exception as e:
200 logger.error(f"Failed to load bumpversion configuration: {e}")
201 raise typer.Exit(code=1)
203 logger.info(f"Current version: {typer.style(current_version_str, fg=typer.colors.CYAN, bold=True)}")
205 # Determine new version string
206 if version:
207 new_version_str = _parse_version_argument(version, current_version_str)
208 else:
209 new_version_str = _get_interactive_bump_type(config)
211 logger.info(f"New version will be: {new_version_str}")
213 # Run bump-my-version
214 logger.info("Running bump-my-version...")
215 setup_logging(verbose=1 if verbose else 0)
217 try:
218 do_bump(
219 version_part=None,
220 new_version=new_version_str,
221 config=config,
222 config_file=config_path,
223 dry_run=dry_run,
224 )
225 except Exception as e:
226 logger.error(f"bump-my-version failed: {e}")
227 raise typer.Exit(code=1)
229 if not dry_run:
230 # Re-read config to get updated version
231 # Note: Since we removed current_version from config file, we should read from pyproject.toml again
232 updated_version = get_current_version()
233 logger.success(f"Version bumped: {current_version_str} -> {updated_version}")
234 logger.info("Don't forget to run 'uv lock' to update the lockfile if needed.")