Coverage for src / rhiza_tools / commands / _shared.py: 100%
36 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-28 02:21 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-28 02:21 +0000
1"""Shared utilities for rhiza-tools commands.
3This module provides common helpers used across multiple command modules
4(bump, release, rollback) to avoid duplication and ensure consistency.
6Utilities:
7 - COOL_STYLE: Shared questionary styling for interactive prompts
8 - run_git_command: Execute git commands with standard error handling
9 - get_current_version: Read the project version from pyproject.toml
10 - get_current_git_branch: Safely determine the current git branch
11 - validate_pyproject_exists: Guard against missing pyproject.toml
12"""
14import subprocess # nosec B404 - subprocess needed for git operations
15from pathlib import Path
16from typing import Any, cast
18import questionary as qs
19import tomlkit
20import typer
22from rhiza_tools import console
24try:
25 from prompt_toolkit.output.win32 import NoConsoleScreenBufferError as _WinConsoleError
26except (ImportError, AssertionError):
28 class _WinConsoleError(Exception): # type: ignore[no-redef]
29 """Sentinel: never raised outside of Windows environments."""
32# Tuple of exceptions indicating a non-interactive environment (no TTY).
33# Use this in except clauses instead of bare ``EOFError`` so that Windows CI
34# (which raises ``NoConsoleScreenBufferError`` instead of ``EOFError``) is
35# handled consistently.
36NON_INTERACTIVE_ERRORS: tuple[type[BaseException], ...] = (EOFError, _WinConsoleError)
38COOL_STYLE = qs.Style(
39 [
40 ("separator", "fg:#cc5454"),
41 ("qmark", "fg:#2FA4A9 bold"),
42 ("question", ""),
43 ("selected", "fg:#2FA4A9 bold"),
44 ("pointer", "fg:#2FA4A9 bold"),
45 ("highlighted", "fg:#2FA4A9 bold"),
46 ("answer", "fg:#2FA4A9 bold"),
47 ("text", "fg:#ffffff"),
48 ("disabled", "fg:#858585 italic"),
49 ]
50)
53def run_git_command(command: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
54 """Run a git command and return the result.
56 Args:
57 command: The git command to run as a list of arguments.
58 check: If True, raise an exception on non-zero exit code.
60 Returns:
61 CompletedProcess instance with stdout, stderr, and returncode.
63 Raises:
64 subprocess.CalledProcessError: If check=True and command fails.
66 Example:
67 >>> result = run_git_command(["git", "status", "--porcelain"]) # doctest: +SKIP
68 >>> print(result.stdout) # doctest: +SKIP
69 """
70 result = subprocess.run(command, capture_output=True, text=True, check=False) # nosec B603 - git commands are trusted # noqa: S603
71 if check and result.returncode != 0:
72 console.error(f"Git command failed: {' '.join(command)}")
73 console.error(f"Error: {result.stderr}")
74 raise subprocess.CalledProcessError(result.returncode, command, result.stdout, result.stderr)
75 return result
78def get_current_version() -> str:
79 """Read current version from pyproject.toml.
81 Returns:
82 The current version string from the project.version field.
84 Raises:
85 typer.Exit: If pyproject.toml cannot be read or parsed.
87 Example:
88 >>> version = get_current_version() # doctest: +SKIP
89 >>> print(version) # doctest: +SKIP
90 0.1.0
91 """
92 try:
93 with open("pyproject.toml") as f:
94 data = tomlkit.parse(f.read())
95 project = cast(dict[str, Any], data["project"])
96 return str(project["version"])
97 except Exception as e:
98 console.error(f"Failed to read version from pyproject.toml: {e}")
99 raise typer.Exit(code=1) from None
102def get_current_git_branch() -> str:
103 """Get the current git branch name.
105 This is the safe variant that returns ``"unknown"`` on failure,
106 suitable for display purposes. For strict validation use
107 :func:`run_git_command` directly.
109 Returns:
110 Current branch name or "unknown" if unable to determine.
111 """
112 result = run_git_command(["git", "rev-parse", "--abbrev-ref", "HEAD"], check=False)
113 return result.stdout.strip() if result.returncode == 0 else "unknown"
116def validate_pyproject_exists() -> None:
117 """Validate that pyproject.toml exists in the current directory.
119 Raises:
120 typer.Exit: If pyproject.toml is not found.
121 """
122 if not Path("pyproject.toml").exists():
123 console.error("pyproject.toml not found in current directory")
124 raise typer.Exit(code=1)