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

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

2 

3from pathlib import Path 

4 

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 

13 

14from rhiza_tools.config import CONFIG_FILENAME 

15 

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) 

29 

30# Valid bump type keywords 

31_VALID_BUMP_TYPES = ["patch", "minor", "major", "prerelease", "build", "alpha", "beta", "rc", "dev"] 

32 

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} 

45 

46 

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) 

56 

57 

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) 

67 

68 

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

75 

76 

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) 

85 

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() 

91 

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

96 

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() 

113 

114 if not choice: 

115 raise typer.Exit(code=0) 

116 

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 

122 

123 

124def _parse_version_argument(version: str | None, current_version_str: str) -> str: 

125 """Parse version argument and return explicit version string. 

126 

127 Args: 

128 version: The version argument provided by the user. 

129 current_version_str: The current version string. 

130 

131 Returns: 

132 The explicit version string to bump to. 

133 """ 

134 if not version: 

135 return "" 

136 

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) 

142 

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

156 

157 # Otherwise, it's an explicit version 

158 # Strip 'v' prefix 

159 if version.startswith("v"): 

160 version = version[1:] 

161 

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) 

169 

170 return version 

171 

172 

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) 

185 

186 # Get current version from pyproject.toml 

187 current_version_str = get_current_version() 

188 

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 

196 

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) 

202 

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

204 

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) 

210 

211 logger.info(f"New version will be: {new_version_str}") 

212 

213 # Run bump-my-version 

214 logger.info("Running bump-my-version...") 

215 setup_logging(verbose=1 if verbose else 0) 

216 

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) 

228 

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