Skip to content

Commit 945f42e

Browse files
committed
✨ feat(version): support release bump to remove prerelease && handle invalid version strings robustly
1 parent 7c4947a commit 945f42e

File tree

3 files changed

+275
-136
lines changed

3 files changed

+275
-136
lines changed

tests/unit/test_version.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,98 @@ def test_version_choice_prepatch(self):
182182
assert choice.next_version.patch == 4
183183
assert choice.next_version.release == "{RELEASE}"
184184

185+
def test_version_choice_major_removes_prerelease(self):
186+
"""Test VersionChoice removes prerelease suffix for major bump to create stable release."""
187+
prev_version = Version(major=0, minor=5, patch=0, release="beta")
188+
choice = VersionChoice(prev_version, "major")
189+
assert choice.bump == "major"
190+
assert choice.next_version.major == 1
191+
assert choice.next_version.minor == 0
192+
assert choice.next_version.patch == 0
193+
assert choice.next_version.release is None
194+
assert choice.next_version.build is None
195+
196+
def test_version_choice_minor_removes_prerelease(self):
197+
"""Test VersionChoice removes prerelease suffix for minor bump to create stable release."""
198+
prev_version = Version(major=0, minor=5, patch=0, release="beta")
199+
choice = VersionChoice(prev_version, "minor")
200+
assert choice.bump == "minor"
201+
assert choice.next_version.major == 0
202+
assert choice.next_version.minor == 6
203+
assert choice.next_version.patch == 0
204+
assert choice.next_version.release is None
205+
assert choice.next_version.build is None
206+
207+
def test_version_choice_patch_removes_prerelease(self):
208+
"""Test VersionChoice removes prerelease suffix for patch bump to create stable release."""
209+
prev_version = Version(major=0, minor=5, patch=0, release="beta")
210+
choice = VersionChoice(prev_version, "patch")
211+
assert choice.bump == "patch"
212+
assert choice.next_version.major == 0
213+
assert choice.next_version.minor == 5
214+
assert choice.next_version.patch == 1
215+
assert choice.next_version.release is None
216+
assert choice.next_version.build is None
217+
218+
def test_version_choice_release_removes_prerelease_suffix(self):
219+
"""Test VersionChoice with 'release' removes prerelease suffix without changing version numbers."""
220+
prev_version = Version(major=1, minor=0, patch=0, release="beta")
221+
choice = VersionChoice(prev_version, "release")
222+
assert choice.bump == "release"
223+
assert choice.next_version.major == 1
224+
assert choice.next_version.minor == 0
225+
assert choice.next_version.patch == 0
226+
assert choice.next_version.release is None
227+
assert choice.next_version.build is None
228+
229+
def test_version_choice_release_with_build_metadata(self):
230+
"""Test VersionChoice with 'release' removes both prerelease and build metadata."""
231+
prev_version = Version(major=2, minor=1, patch=3, release="rc.1", build="20231201")
232+
choice = VersionChoice(prev_version, "release")
233+
assert choice.bump == "release"
234+
assert choice.next_version.major == 2
235+
assert choice.next_version.minor == 1
236+
assert choice.next_version.patch == 3
237+
assert choice.next_version.release is None
238+
assert choice.next_version.build is None
239+
240+
@patch("tgit.version._prompt_for_version_choice")
241+
@patch("tgit.version._apply_version_choice")
242+
def test_handle_interactive_version_selection_includes_release_for_prerelease(self, mock_apply, mock_prompt):
243+
"""Test that 'release' option is included when current version is prerelease."""
244+
prev_version = Version(1, 0, 0, release="beta")
245+
mock_choice = Mock()
246+
mock_choice.bump = "release"
247+
mock_prompt.return_value = mock_choice
248+
mock_apply.return_value = Version(1, 0, 0)
249+
250+
_handle_interactive_version_selection(prev_version, "patch", 0)
251+
252+
# Verify that _prompt_for_version_choice was called with choices including 'release'
253+
assert mock_prompt.called
254+
choices_arg = mock_prompt.call_args[0][0]
255+
bump_types = [choice.bump for choice in choices_arg]
256+
assert "release" in bump_types
257+
assert bump_types[0] == "release" # Should be first option for prominence
258+
259+
@patch("tgit.version._prompt_for_version_choice")
260+
@patch("tgit.version._apply_version_choice")
261+
def test_handle_interactive_version_selection_no_release_for_stable(self, mock_apply, mock_prompt):
262+
"""Test that 'release' option is not included when current version is stable."""
263+
prev_version = Version(1, 0, 0) # No prerelease suffix
264+
mock_choice = Mock()
265+
mock_choice.bump = "patch"
266+
mock_prompt.return_value = mock_choice
267+
mock_apply.return_value = Version(1, 0, 1)
268+
269+
_handle_interactive_version_selection(prev_version, "patch", 0)
270+
271+
# Verify that _prompt_for_version_choice was called with choices not including 'release'
272+
assert mock_prompt.called
273+
choices_arg = mock_prompt.call_args[0][0]
274+
bump_types = [choice.bump for choice in choices_arg]
275+
assert "release" not in bump_types
276+
185277
def test_version_choice_str(self):
186278
"""Test string representation of VersionChoice."""
187279
prev_version = Version(major=1, minor=2, patch=3)
@@ -216,6 +308,14 @@ def test_get_version_from_package_json_no_version(self, tmp_path):
216308
version = get_version_from_package_json(tmp_path)
217309
assert version is None
218310

311+
def test_get_version_from_package_json_invalid_version(self, tmp_path):
312+
"""Test extracting invalid version from package.json returns None."""
313+
package_json = tmp_path / "package.json"
314+
package_json.write_text('{"version": "invalid-version"}')
315+
316+
version = get_version_from_package_json(tmp_path)
317+
assert version is None
318+
219319
def test_get_version_from_pyproject_toml(self, tmp_path):
220320
"""Test extracting version from pyproject.toml."""
221321
pyproject_toml = tmp_path / "pyproject.toml"

tgit/version.py

Lines changed: 70 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ def __init__(self, previous_version: Version, bump: str) -> None:
118118
patch=previous_version.patch + 1,
119119
release="{RELEASE}",
120120
)
121+
elif bump == "release":
122+
# Remove prerelease suffix to create a stable release
123+
self.next_version = Version(
124+
major=previous_version.major,
125+
minor=previous_version.minor,
126+
patch=previous_version.patch,
127+
)
121128
elif bump == "previous":
122129
self.next_version = previous_version
123130

@@ -162,23 +169,35 @@ def get_version_from_package_json(path: Path) -> Version | None:
162169
with package_json_path.open() as f:
163170
json_data = json.load(f)
164171
if version := json_data.get("version"):
165-
return Version.from_str(version)
172+
try:
173+
return Version.from_str(version)
174+
except ValueError:
175+
return None
166176
return None
167177

168178

169179
def get_version_from_pyproject_toml(path: Path) -> Version | None:
170180
pyproject_toml_path = path / "pyproject.toml"
171-
if pyproject_toml_path.exists():
172-
with pyproject_toml_path.open("rb") as f:
173-
toml_data = tomllib.load(f)
174-
if version := toml_data.get("project", {}).get("version"):
175-
return Version.from_str(version)
176-
if version := toml_data.get("tool", {}).get("poetry", {}).get("version"):
177-
return Version.from_str(version)
178-
if version := toml_data.get("tool", {}).get("flit", {}).get("metadata", {}).get("version"):
179-
return Version.from_str(version)
180-
if version := toml_data.get("tool", {}).get("setuptools", {}).get("setup_requires", {}).get("version"):
181+
if not pyproject_toml_path.exists():
182+
return None
183+
184+
with pyproject_toml_path.open("rb") as f:
185+
toml_data = tomllib.load(f)
186+
187+
version_paths = [
188+
toml_data.get("project", {}).get("version"),
189+
toml_data.get("tool", {}).get("poetry", {}).get("version"),
190+
toml_data.get("tool", {}).get("flit", {}).get("metadata", {}).get("version"),
191+
toml_data.get("tool", {}).get("setuptools", {}).get("setup_requires", {}).get("version"),
192+
]
193+
194+
for version in version_paths:
195+
if version:
196+
try:
181197
return Version.from_str(version)
198+
except ValueError:
199+
continue
200+
182201
return None
183202

184203

@@ -188,7 +207,10 @@ def get_version_from_setup_py(path: Path) -> Version | None:
188207
with setup_py_path.open() as f:
189208
setup_data = f.read()
190209
if res := re.search(r"version=['\"]([^'\"]+)['\"]", setup_data):
191-
return Version.from_str(res[1])
210+
try:
211+
return Version.from_str(res[1])
212+
except ValueError:
213+
return None
192214
return None
193215

194216

@@ -211,41 +233,42 @@ def get_version_from_cargo_toml(directory_path: Path) -> Version | None:
211233
if not cargo_toml_path.is_file():
212234
console.print(f"Cargo.toml not found or is not a file at: {cargo_toml_path}")
213235
return None
236+
237+
# 2. Load and parse the TOML file
214238
try:
215-
# 2. Open and read the file
216239
with cargo_toml_path.open("rb") as f:
217-
try:
218-
# 3. Parse TOML content
219-
cargo_data = tomllib.load(f)
220-
except tomllib.TOMLDecodeError as e:
221-
console.print(f"Failed to decode TOML file {cargo_toml_path}: {e}")
222-
return None
223-
224-
except OSError as e:
225-
# Handle potential file reading errors (permissions, etc.)
226-
console.print(f"Could not read file {cargo_toml_path}: {e}")
240+
cargo_data = tomllib.load(f)
241+
except (OSError, tomllib.TOMLDecodeError) as e:
242+
console.print(f"Could not read or parse TOML file {cargo_toml_path}: {e}")
227243
return None
228244

229-
# 4. Safely access the package table
245+
# 3. Safely access the package table and version string
230246
package_data = cargo_data.get("package")
231247
if not isinstance(package_data, dict):
232248
console.print(f"Missing or invalid [package] table in {cargo_toml_path}")
233249
return None
234250

235-
# 5. Safely access the version string
236251
version_str = package_data.get("version") # type: ignore
237252
if not isinstance(version_str, str) or not version_str: # Check if it's a non-empty string
238253
console.print(f"Missing, empty, or invalid 'version' string in [package] table of {cargo_toml_path}")
239254
return None
240-
return Version.from_str(version_str)
255+
256+
# 4. Parse and return the version
257+
try:
258+
return Version.from_str(version_str)
259+
except ValueError:
260+
return None
241261

242262

243263
def get_version_from_version_file(path: Path) -> Version | None:
244264
version_path = path / "VERSION"
245265
if version_path.exists():
246266
with version_path.open() as f:
247267
version = f.read().strip()
248-
return Version.from_str(version)
268+
try:
269+
return Version.from_str(version)
270+
except ValueError:
271+
return None
249272
return None
250273

251274

@@ -254,7 +277,10 @@ def get_version_from_version_txt(path: Path) -> Version | None:
254277
if version_txt_path.exists():
255278
with version_txt_path.open() as f:
256279
version = f.read().strip()
257-
return Version.from_str(version)
280+
try:
281+
return Version.from_str(version)
282+
except ValueError:
283+
return None
258284
return None
259285

260286

@@ -269,7 +295,10 @@ def get_version_from_git(path: Path) -> Version | None:
269295
tags = status.stdout.decode().split("\n")
270296
for tag in tags:
271297
if tag.startswith("v"):
272-
return Version.from_str(tag[1:])
298+
try:
299+
return Version.from_str(tag[1:])
300+
except ValueError:
301+
continue
273302
return None
274303

275304

@@ -517,9 +546,15 @@ def _handle_explicit_version_args(args: VersionArgs, prev_version: Version) -> V
517546

518547

519548
def _handle_interactive_version_selection(prev_version: Version, default_bump: str, verbose: int) -> Version | None:
520-
choices = [
521-
VersionChoice(prev_version, bump) for bump in ["patch", "minor", "major", "prepatch", "preminor", "premajor", "previous", "custom"]
522-
]
549+
bump_options = ["patch", "minor", "major", "prepatch", "preminor", "premajor"]
550+
551+
# Add "release" option if current version is a prerelease
552+
if prev_version.release:
553+
bump_options.insert(0, "release") # Put it at the beginning for prominence
554+
555+
bump_options.extend(["previous", "custom"])
556+
557+
choices = [VersionChoice(prev_version, bump) for bump in bump_options]
523558
default_choice = next((choice for choice in choices if choice.bump == default_bump), None)
524559

525560
console.print(f"Auto bump based on commits: [cyan bold]{default_bump}")
@@ -569,6 +604,9 @@ def _apply_version_choice(target: VersionChoice, prev_version: Version) -> Versi
569604
next_version = custom_version
570605
else:
571606
return None
607+
elif target.bump == "release":
608+
# Remove prerelease suffix - next_version is already set correctly in VersionChoice
609+
next_version = target.next_version
572610
else:
573611
# For regular bumps: patch, minor, major, previous
574612
bump_version(target, next_version)

0 commit comments

Comments
 (0)