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
« 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.
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"""
10from collections.abc import Callable
12import semver
13import typer
15from rhiza_tools import console
16from rhiza_tools.commands._shared import parse_semver_or_exit
19def _denormalize_pep440_to_semver(version_str: str) -> str:
20 """Convert PEP 440 prerelease format to semver format.
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.
25 Args:
26 version_str: Version string, possibly in PEP 440 format.
28 Returns:
29 Version string in semver format.
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
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)
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}"
60 # If not a PEP 440 prerelease, return as-is
61 return version_str
64# Valid bump type keywords
65_VALID_BUMP_TYPES = ["patch", "minor", "major", "prerelease", "build", "alpha", "beta", "rc", "dev"]
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}
81def get_next_prerelease(current_version: semver.Version, token: str) -> semver.Version:
82 """Calculate next prerelease version for a given token.
84 Args:
85 current_version: The current semantic version.
86 token: The prerelease token (e.g., "alpha", "beta", "rc", "dev").
88 Returns:
89 The next prerelease version with the specified token.
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)
107def _determine_bump_type_from_choice(choice: str) -> str:
108 """Extract bump type from interactive choice string.
110 Args:
111 choice: The choice string selected by the user (e.g., "Patch (1.0.0 -> 1.0.1)").
113 Returns:
114 The bump type extracted from the choice prefix (e.g., "patch").
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 ""
127def get_bumped_version_from_type(current_version: semver.Version, version_type: str) -> str:
128 """Get bumped version string from version type keyword.
130 Args:
131 current_version: The current semantic version.
132 version_type: The bump type keyword.
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 }
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))
150 return ""
153def _validate_explicit_version(version: str) -> str:
154 """Validate and clean explicit version string.
156 Args:
157 version: Version string to validate.
159 Returns:
160 Cleaned version string.
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
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
176 return cleaned_version
179def _parse_version_argument(version: str | None, current_version_str: str) -> str:
180 """Parse version argument and return explicit version string.
182 Converts bump type keywords (patch, minor, major, etc.) to explicit version
183 strings, or validates and returns explicit version strings.
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.
190 Returns:
191 The explicit version string to bump to, or empty string if version is None.
193 Raises:
194 typer.Exit: If the version format is invalid.
196 Example:
197 >>> version = _parse_version_argument("patch", "1.0.0")
198 >>> print(version)
199 1.0.1
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 ""
208 current_version = parse_semver_or_exit(current_version_str)
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
215 # Otherwise, it's an explicit version - validate and return
216 return _validate_explicit_version(version)