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

156 statements  

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

1"""Project I/O and interactive UI helpers for the bump command. 

2 

3This module holds the ``Language`` enum and ``BumpOptions`` dataclass (the public 

4data model for bump), plus the helpers that read project files 

5(``get_current_version``, ``_validate_project_exists``), show interactive prompts 

6(``get_interactive_bump_type``, ``_show_interactive_preview``), and log the outcome 

7(``_log_bump_success``). 

8 

9All symbols defined here are re-exported by ``bump.py`` so the public import 

10surface is unchanged. 

11""" 

12 

13from __future__ import annotations 

14 

15import tomllib 

16from dataclasses import dataclass 

17from enum import StrEnum 

18from pathlib import Path 

19from typing import cast 

20 

21import questionary as qs 

22import typer 

23from loguru import logger 

24 

25from rhiza_tools import console 

26from rhiza_tools.commands._shared import ( 

27 COOL_STYLE, 

28 NON_INTERACTIVE_ERRORS, 

29 parse_semver_or_exit, 

30) 

31from rhiza_tools.commands.bump.engine import BumpConfig, _get_files_to_modify 

32from rhiza_tools.commands.bump.versioning import ( 

33 _denormalize_pep440_to_semver, 

34 get_next_prerelease, 

35) 

36 

37 

38class Language(StrEnum): 

39 """Supported programming languages for version bumping. 

40 

41 Attributes: 

42 PYTHON: Python projects using pyproject.toml 

43 GO: Go projects using VERSION file with go.mod 

44 """ 

45 

46 PYTHON = "python" 

47 GO = "go" 

48 

49 @classmethod 

50 def detect(cls) -> Language | None: 

51 """Detect the project language based on files present. 

52 

53 Returns: 

54 Language enum if detected, None if no supported language is found. 

55 

56 Example: 

57 >>> lang = Language.detect() # doctest: +SKIP 

58 >>> if lang: 

59 ... print(lang.value) # doctest: +SKIP 

60 python 

61 """ 

62 if Path("pyproject.toml").exists(): 

63 return cls.PYTHON 

64 elif Path("go.mod").exists() and Path("VERSION").exists(): 

65 return cls.GO 

66 return None 

67 

68 def get_version_file(self) -> Path: 

69 """Get the version file path for this language. 

70 

71 Returns: 

72 Path to the version file. 

73 

74 Example: 

75 >>> lang = Language.PYTHON 

76 >>> lang.get_version_file() # doctest: +SKIP 

77 PosixPath('pyproject.toml') 

78 """ 

79 if self == Language.PYTHON: 

80 return Path("pyproject.toml") 

81 # Language.GO 

82 return Path("VERSION") 

83 

84 

85# User-facing hint listing the languages accepted by ``--language``. Defined once 

86# so the message stays consistent everywhere it is shown (invalid value, failed 

87# auto-detection). 

88SUPPORTED_LANGUAGES_MSG = "Supported languages: python, go" 

89 

90 

91def parse_language_option(language: str | None) -> Language | None: 

92 """Parse the ``--language`` CLI option into a :class:`Language`. 

93 

94 Args: 

95 language: The raw ``--language`` value, or ``None`` when the option was 

96 not supplied (auto-detection happens later). 

97 

98 Returns: 

99 The matching :class:`Language`, or ``None`` when ``language`` is ``None``. 

100 

101 Raises: 

102 typer.Exit: If ``language`` is given but is not a supported value. 

103 """ 

104 if language is None: 

105 return None 

106 try: 

107 return Language(language.lower()) 

108 except ValueError: 

109 console.error(f"Invalid language: {language}") 

110 console.error(SUPPORTED_LANGUAGES_MSG) 

111 raise typer.Exit(code=1) from None 

112 

113 

114@dataclass 

115class BumpOptions: 

116 """Configuration options for bump command. 

117 

118 Attributes: 

119 version: The version to bump to. Can be an explicit version, bump type, or None. 

120 dry_run: If True, show what would change without actually changing anything. 

121 commit: If True, automatically commit the version change to git. 

122 push: If True, push changes to remote after commit (implies commit=True). 

123 branch: Branch to perform the bump on (default: current branch). 

124 allow_dirty: If True, allow bumping even with uncommitted changes. 

125 language: The programming language (python or go). If None, auto-detect. 

126 config: Path to the .cfg.toml config file. Defaults to CONFIG_FILENAME. 

127 """ 

128 

129 version: str | None = None 

130 dry_run: bool = False 

131 commit: bool = False 

132 push: bool = False 

133 branch: str | None = None 

134 allow_dirty: bool = False 

135 language: Language | None = None 

136 config: Path | None = None 

137 

138 

139def get_current_version(language: Language) -> str: 

140 """Read current version from project configuration for the specified language. 

141 

142 Args: 

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

144 

145 Returns: 

146 The current version string in semver format (for compatibility with bump logic). 

147 

148 Raises: 

149 typer.Exit: If version cannot be read or parsed. 

150 

151 Example: 

152 >>> version = get_current_version(Language.PYTHON) # doctest: +SKIP 

153 >>> print(version) # doctest: +SKIP 

154 0.1.0 

155 """ 

156 if language == Language.PYTHON: 

157 try: 

158 with open("pyproject.toml", "rb") as f: 

159 data = tomllib.load(f) 

160 # Convert PEP 440 format back to semver format for compatibility 

161 # e.g., 0.1.1a1 -> 0.1.1-alpha.1 

162 return _denormalize_pep440_to_semver(str(data["project"]["version"])) 

163 except (OSError, tomllib.TOMLDecodeError, KeyError) as e: 

164 console.error(f"Failed to read version from pyproject.toml: {e}") 

165 raise typer.Exit(code=1) from None 

166 elif language == Language.GO: 

167 try: 

168 with open("VERSION") as f: 

169 version = f.read().strip() 

170 except OSError as e: 

171 console.error(f"Failed to read version from VERSION file: {e}") 

172 raise typer.Exit(code=1) from None 

173 

174 if not version: 

175 console.error("VERSION file is empty") 

176 raise typer.Exit(code=1) 

177 

178 # Validate that the version string is not just whitespace and looks valid 

179 if not version or version.isspace(): 

180 console.error("VERSION file contains only whitespace") 

181 raise typer.Exit(code=1) 

182 

183 return version 

184 else: 

185 console.error(f"Unsupported language: {language}") 

186 raise typer.Exit(code=1) 

187 

188 

189def get_interactive_bump_type(current_version_str: str) -> str: 

190 """Get bump type from user through interactive prompt. 

191 

192 Displays an interactive menu with all available bump types and their 

193 resulting versions. Returns the selected new version string. 

194 

195 Args: 

196 current_version_str: The current version string (semver-compatible). 

197 

198 Returns: 

199 The new version string selected by the user. 

200 

201 Raises: 

202 typer.Exit: If the current version is invalid or user cancels selection. 

203 

204 Example: 

205 Interactive prompt shows:: 

206 

207 Select bump type (Current: 1.0.0) 

208 > Patch (1.0.0 -> 1.0.1) 

209 Minor (1.0.0 -> 1.1.0) 

210 Major (1.0.0 -> 2.0.0) 

211 ... 

212 """ 

213 current_version = parse_semver_or_exit(current_version_str) 

214 

215 next_patch = current_version.bump_patch() 

216 next_minor = current_version.bump_minor() 

217 next_major = current_version.bump_major() 

218 next_prerelease = current_version.bump_prerelease() 

219 next_build = current_version.bump_build() 

220 

221 next_alpha = get_next_prerelease(current_version, "alpha") 

222 next_beta = get_next_prerelease(current_version, "beta") 

223 next_rc = get_next_prerelease(current_version, "rc") 

224 next_dev = get_next_prerelease(current_version, "dev") 

225 

226 try: 

227 choice = qs.select( 

228 f"Select bump type (Current: {current_version_str})", 

229 choices=[ 

230 f"Patch ({current_version_str} -> {next_patch})", 

231 f"Minor ({current_version_str} -> {next_minor})", 

232 f"Major ({current_version_str} -> {next_major})", 

233 qs.Separator("-" * 30), 

234 f"Prerelease ({current_version_str} -> {next_prerelease})", 

235 f"Alpha ({current_version_str} -> {next_alpha})", 

236 f"Beta ({current_version_str} -> {next_beta})", 

237 f"RC ({current_version_str} -> {next_rc})", 

238 f"Dev ({current_version_str} -> {next_dev})", 

239 f"Build ({current_version_str} -> {next_build})", 

240 ], 

241 style=COOL_STYLE, 

242 ).ask() 

243 except NON_INTERACTIVE_ERRORS: 

244 console.error("Interactive selection not available in non-interactive environment") 

245 raise typer.Exit(code=1) from None 

246 

247 if not choice: 

248 raise typer.Exit(code=0) 

249 

250 # Extract the new version string from the choice. Each menu label has the 

251 # form Label (Current -> New); the portion after the arrow is what we keep. 

252 # Check if the choice contains the expected format (skip separators) 

253 if "-> " not in choice: 

254 console.error("Invalid choice selection") 

255 raise typer.Exit(code=1) 

256 

257 new_version: str = choice.split("-> ")[1].rstrip(")") 

258 return new_version 

259 

260 

261def _validate_project_exists(language: Language) -> None: 

262 """Validate that required project files exist for the specified language. 

263 

264 Args: 

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

266 

267 Raises: 

268 typer.Exit: If required project files are not found. 

269 """ 

270 if language == Language.PYTHON: 

271 if not Path("pyproject.toml").exists(): 

272 console.error("Python project detected but pyproject.toml not found.") 

273 console.error("Please create a pyproject.toml file with the current version.") 

274 raise typer.Exit(code=1) 

275 elif language == Language.GO: 

276 if not Path("go.mod").exists(): 

277 console.error("Go language specified but go.mod not found.") 

278 console.error("Please create a go.mod file for your Go project.") 

279 raise typer.Exit(code=1) 

280 if not Path("VERSION").exists(): 

281 console.error("Go project detected but VERSION file not found.") 

282 console.error("Please create a VERSION file with the current version.") 

283 raise typer.Exit(code=1) 

284 else: 

285 console.error(f"Unsupported language: {language}") 

286 raise typer.Exit(code=1) 

287 

288 

289def _log_bump_success(current_version_str: str, config: BumpConfig, language: Language) -> None: 

290 """Log successful version bump and post-bump instructions. 

291 

292 Args: 

293 current_version_str: The original version string before the bump. 

294 config: The bumpversion configuration object. 

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

296 """ 

297 updated_version = get_current_version(language) 

298 success_msg = ( 

299 f"\n{typer.style('✓', fg=typer.colors.GREEN, bold=True)} " 

300 f"Version bumped: {current_version_str} -> {updated_version}" 

301 ) 

302 console.success(success_msg) 

303 

304 # Show which files were actually modified 

305 files = _get_files_to_modify(config) 

306 if files: 

307 console.info(f"\n{typer.style('Modified files:', fg=typer.colors.CYAN, bold=True)}") 

308 for file_path in files: 

309 if file_path.exists(): 

310 console.info(f"{file_path}") 

311 else: 

312 # Show common files that typically get modified 

313 console.info(f"\n{typer.style('Modified files:', fg=typer.colors.CYAN, bold=True)}") 

314 for file_path in [Path("pyproject.toml"), Path("VERSION"), Path("setup.py"), Path("setup.cfg")]: 

315 if file_path.exists(): 

316 # Check if file was actually modified by checking content 

317 try: 

318 content = file_path.read_text() 

319 if updated_version in content: 

320 console.info(f"{file_path}") 

321 except Exception: # nosec B110 - safe to ignore file read errors # noqa: S110, BLE001 

322 pass 

323 

324 console.info("\nDon't forget to run 'uv lock' to update the lockfile if needed.") 

325 

326 

327def _show_interactive_preview( 

328 current_version_str: str, 

329 new_version_str: str, 

330 current_git_branch: str, 

331) -> tuple[bool, bool, bool]: 

332 """Show interactive preview and prompt for commit/push decisions. 

333 

334 In interactive mode, the user is asked step-by-step whether to proceed 

335 with the bump, whether to commit the changes, and whether to push. 

336 

337 Args: 

338 current_version_str: Current version. 

339 new_version_str: New version. 

340 current_git_branch: Current git branch. 

341 

342 Returns: 

343 Tuple of (proceed, commit, push). ``proceed`` is False if the user 

344 cancels the bump entirely. 

345 """ 

346 # Show preview 

347 console.info("\nPreview of changes:") 

348 console.info(f" Version: {current_version_str}{new_version_str}") 

349 console.info(f" Branch: {current_git_branch}") 

350 

351 # Confirm bump 

352 try: 

353 proceed = cast(bool, qs.confirm("Proceed with version bump?", default=True, style=COOL_STYLE).ask()) 

354 except NON_INTERACTIVE_ERRORS: 

355 logger.debug("Running in non-interactive environment, proceeding automatically") 

356 proceed = True 

357 

358 if not proceed: 

359 return False, False, False 

360 

361 # Ask about commit 

362 try: 

363 commit = cast(bool, qs.confirm("Commit the changes?", default=True, style=COOL_STYLE).ask()) 

364 except NON_INTERACTIVE_ERRORS: 

365 logger.debug("Running in non-interactive environment, committing automatically") 

366 commit = True 

367 

368 # Ask about push (only if committing) 

369 push = False 

370 if commit: 

371 try: 

372 push = cast(bool, qs.confirm("Push changes to remote?", default=False, style=COOL_STYLE).ask()) 

373 except NON_INTERACTIVE_ERRORS: 

374 logger.debug("Running in non-interactive environment, skipping push") 

375 push = False 

376 

377 return True, commit, push