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
« 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.
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``).
9All symbols defined here are re-exported by ``bump.py`` so the public import
10surface is unchanged.
11"""
13from __future__ import annotations
15import tomllib
16from dataclasses import dataclass
17from enum import StrEnum
18from pathlib import Path
19from typing import cast
21import questionary as qs
22import typer
23from loguru import logger
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)
38class Language(StrEnum):
39 """Supported programming languages for version bumping.
41 Attributes:
42 PYTHON: Python projects using pyproject.toml
43 GO: Go projects using VERSION file with go.mod
44 """
46 PYTHON = "python"
47 GO = "go"
49 @classmethod
50 def detect(cls) -> Language | None:
51 """Detect the project language based on files present.
53 Returns:
54 Language enum if detected, None if no supported language is found.
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
68 def get_version_file(self) -> Path:
69 """Get the version file path for this language.
71 Returns:
72 Path to the version file.
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")
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"
91def parse_language_option(language: str | None) -> Language | None:
92 """Parse the ``--language`` CLI option into a :class:`Language`.
94 Args:
95 language: The raw ``--language`` value, or ``None`` when the option was
96 not supplied (auto-detection happens later).
98 Returns:
99 The matching :class:`Language`, or ``None`` when ``language`` is ``None``.
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
114@dataclass
115class BumpOptions:
116 """Configuration options for bump command.
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 """
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
139def get_current_version(language: Language) -> str:
140 """Read current version from project configuration for the specified language.
142 Args:
143 language: The programming language (python or go).
145 Returns:
146 The current version string in semver format (for compatibility with bump logic).
148 Raises:
149 typer.Exit: If version cannot be read or parsed.
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
174 if not version:
175 console.error("VERSION file is empty")
176 raise typer.Exit(code=1)
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)
183 return version
184 else:
185 console.error(f"Unsupported language: {language}")
186 raise typer.Exit(code=1)
189def get_interactive_bump_type(current_version_str: str) -> str:
190 """Get bump type from user through interactive prompt.
192 Displays an interactive menu with all available bump types and their
193 resulting versions. Returns the selected new version string.
195 Args:
196 current_version_str: The current version string (semver-compatible).
198 Returns:
199 The new version string selected by the user.
201 Raises:
202 typer.Exit: If the current version is invalid or user cancels selection.
204 Example:
205 Interactive prompt shows::
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)
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()
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")
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
247 if not choice:
248 raise typer.Exit(code=0)
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)
257 new_version: str = choice.split("-> ")[1].rstrip(")")
258 return new_version
261def _validate_project_exists(language: Language) -> None:
262 """Validate that required project files exist for the specified language.
264 Args:
265 language: The programming language (python or go).
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)
289def _log_bump_success(current_version_str: str, config: BumpConfig, language: Language) -> None:
290 """Log successful version bump and post-bump instructions.
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)
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
324 console.info("\nDon't forget to run 'uv lock' to update the lockfile if needed.")
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.
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.
337 Args:
338 current_version_str: Current version.
339 new_version_str: New version.
340 current_git_branch: Current git branch.
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}")
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
358 if not proceed:
359 return False, False, False
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
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
377 return True, commit, push