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

1"""Shared utilities for rhiza-tools commands. 

2 

3This module provides common helpers used across multiple command modules 

4(bump, release, rollback) to avoid duplication and ensure consistency. 

5 

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

13 

14import subprocess # nosec B404 - subprocess needed for git operations 

15from pathlib import Path 

16from typing import Any, cast 

17 

18import questionary as qs 

19import tomlkit 

20import typer 

21 

22from rhiza_tools import console 

23 

24try: 

25 from prompt_toolkit.output.win32 import NoConsoleScreenBufferError as _WinConsoleError 

26except (ImportError, AssertionError): 

27 

28 class _WinConsoleError(Exception): # type: ignore[no-redef] 

29 """Sentinel: never raised outside of Windows environments.""" 

30 

31 

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) 

37 

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) 

51 

52 

53def run_git_command(command: list[str], check: bool = True) -> subprocess.CompletedProcess[str]: 

54 """Run a git command and return the result. 

55 

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. 

59 

60 Returns: 

61 CompletedProcess instance with stdout, stderr, and returncode. 

62 

63 Raises: 

64 subprocess.CalledProcessError: If check=True and command fails. 

65 

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 

76 

77 

78def get_current_version() -> str: 

79 """Read current version from pyproject.toml. 

80 

81 Returns: 

82 The current version string from the project.version field. 

83 

84 Raises: 

85 typer.Exit: If pyproject.toml cannot be read or parsed. 

86 

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 

100 

101 

102def get_current_git_branch() -> str: 

103 """Get the current git branch name. 

104 

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. 

108 

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" 

114 

115 

116def validate_pyproject_exists() -> None: 

117 """Validate that pyproject.toml exists in the current directory. 

118 

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)