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

52 statements  

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

1"""Pure version-math helpers for the bump command. 

2 

3This module contains the version-arithmetic building blocks used by the bump 

4command: PEP 440 / semver normalization, prerelease calculation, bump-type 

5resolution, and version-argument parsing. It deliberately contains no 

6interactive (``questionary``) code and no bump-my-version integration so the 

7version logic can be reasoned about and tested in isolation. 

8""" 

9 

10from collections.abc import Callable 

11 

12import semver 

13import typer 

14 

15from rhiza_tools import console 

16from rhiza_tools.commands._shared import parse_semver_or_exit 

17 

18 

19def _denormalize_pep440_to_semver(version_str: str) -> str: 

20 """Convert PEP 440 prerelease format to semver format. 

21 

22 Converts PEP 440 format (e.g., 0.1.1a1 or 0.1.1alpha1) back to semver format 

23 (e.g., 0.1.1-alpha.1) for compatibility with the semver library and bump-my-version. 

24 

25 Args: 

26 version_str: Version string, possibly in PEP 440 format. 

27 

28 Returns: 

29 Version string in semver format. 

30 

31 Example: 

32 >>> _denormalize_pep440_to_semver("0.1.1a1") 

33 '0.1.1-alpha.1' 

34 >>> _denormalize_pep440_to_semver("0.1.1alpha1") 

35 '0.1.1-alpha.1' 

36 >>> _denormalize_pep440_to_semver("0.1.1") 

37 '0.1.1' 

38 """ 

39 import re 

40 

41 # Pattern to match PEP 440 prerelease: 0.1.1a1, 0.1.1alpha1, 0.1.1b2, 0.1.1rc3 

42 # Captures: major.minor.patch, release letter(s), and pre_n 

43 pattern = r"^(\d+\.\d+\.\d+)(a|alpha|b|beta|rc|dev)(\d+)$" 

44 match = re.match(pattern, version_str) 

45 

46 if match: 

47 base, release_short, pre_n = match.groups() 

48 # Map PEP 440 forms to full names for semver 

49 release_map = { 

50 "a": "alpha", 

51 "alpha": "alpha", 

52 "b": "beta", 

53 "beta": "beta", 

54 "rc": "rc", 

55 "dev": "dev", 

56 } 

57 release_full = release_map.get(release_short, release_short) 

58 return f"{base}-{release_full}.{pre_n}" 

59 

60 # If not a PEP 440 prerelease, return as-is 

61 return version_str 

62 

63 

64# Valid bump type keywords 

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

66 

67# Mapping of choice prefix to bump type for interactive selection 

68_CHOICE_PREFIX_TO_BUMP_TYPE = { 

69 "Patch": "patch", 

70 "Minor": "minor", 

71 "Major": "major", 

72 "Alpha": "alpha", 

73 "Beta": "beta", 

74 "RC": "rc", 

75 "Dev": "dev", 

76 "Prerelease": "prerelease", 

77 "Build": "build", 

78} 

79 

80 

81def get_next_prerelease(current_version: semver.Version, token: str) -> semver.Version: 

82 """Calculate next prerelease version for a given token. 

83 

84 Args: 

85 current_version: The current semantic version. 

86 token: The prerelease token (e.g., "alpha", "beta", "rc", "dev"). 

87 

88 Returns: 

89 The next prerelease version with the specified token. 

90 

91 Example: 

92 >>> import semver 

93 >>> current = semver.Version.parse("1.0.0") 

94 >>> next_alpha = get_next_prerelease(current, "alpha") 

95 >>> print(next_alpha) 

96 1.0.1-alpha.1 

97 """ 

98 if current_version.prerelease: 

99 if current_version.prerelease.startswith(token): 

100 return current_version.bump_prerelease() 

101 else: 

102 return current_version.replace(prerelease=f"{token}.1") 

103 else: 

104 return current_version.bump_patch().bump_prerelease(token=token) 

105 

106 

107def _determine_bump_type_from_choice(choice: str) -> str: 

108 """Extract bump type from interactive choice string. 

109 

110 Args: 

111 choice: The choice string selected by the user (e.g., "Patch (1.0.0 -> 1.0.1)"). 

112 

113 Returns: 

114 The bump type extracted from the choice prefix (e.g., "patch"). 

115 

116 Example: 

117 >>> bump_type = _determine_bump_type_from_choice("Patch (1.0.0 -> 1.0.1)") 

118 >>> print(bump_type) 

119 patch 

120 """ 

121 for prefix, bump_type in _CHOICE_PREFIX_TO_BUMP_TYPE.items(): 

122 if choice.startswith(prefix): 

123 return bump_type 

124 return "" 

125 

126 

127def get_bumped_version_from_type(current_version: semver.Version, version_type: str) -> str: 

128 """Get bumped version string from version type keyword. 

129 

130 Args: 

131 current_version: The current semantic version. 

132 version_type: The bump type keyword. 

133 

134 Returns: 

135 The bumped version string. 

136 """ 

137 bump_mapping: dict[str, Callable[[], semver.Version]] = { 

138 "patch": current_version.bump_patch, 

139 "minor": current_version.bump_minor, 

140 "major": current_version.bump_major, 

141 "prerelease": current_version.bump_prerelease, 

142 "build": current_version.bump_build, 

143 } 

144 

145 if version_type in bump_mapping: 

146 return str(bump_mapping[version_type]()) 

147 elif version_type in ["alpha", "beta", "rc", "dev"]: 

148 return str(get_next_prerelease(current_version, version_type)) 

149 

150 return "" 

151 

152 

153def _validate_explicit_version(version: str) -> str: 

154 """Validate and clean explicit version string. 

155 

156 Args: 

157 version: Version string to validate. 

158 

159 Returns: 

160 Cleaned version string. 

161 

162 Raises: 

163 typer.Exit: If version format is invalid. 

164 """ 

165 # Strip 'v' prefix 

166 cleaned_version = version[1:] if version.startswith("v") else version 

167 

168 # Validate explicit version 

169 try: 

170 semver.Version.parse(cleaned_version) 

171 except ValueError: 

172 console.error(f"Invalid version format: {version}") 

173 console.error("Please use a valid semantic version.") 

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

175 

176 return cleaned_version 

177 

178 

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

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

181 

182 Converts bump type keywords (patch, minor, major, etc.) to explicit version 

183 strings, or validates and returns explicit version strings. 

184 

185 Args: 

186 version: The version argument provided by the user. Can be a bump type 

187 keyword or an explicit version string. 

188 current_version_str: The current version string. 

189 

190 Returns: 

191 The explicit version string to bump to, or empty string if version is None. 

192 

193 Raises: 

194 typer.Exit: If the version format is invalid. 

195 

196 Example: 

197 >>> version = _parse_version_argument("patch", "1.0.0") 

198 >>> print(version) 

199 1.0.1 

200 

201 >>> version = _parse_version_argument("2.0.0", "1.0.0") 

202 >>> print(version) 

203 2.0.0 

204 """ 

205 if not version: 

206 return "" 

207 

208 current_version = parse_semver_or_exit(current_version_str) 

209 

210 # Try to get bumped version from type keyword 

211 bumped_version = get_bumped_version_from_type(current_version, version) 

212 if bumped_version: 

213 return bumped_version 

214 

215 # Otherwise, it's an explicit version - validate and return 

216 return _validate_explicit_version(version)