Skip to content

Commit b0ac2f2

Browse files
committed
fix(cli): canonicalize self-upgrade version checks
1 parent fda45bb commit b0ac2f2

2 files changed

Lines changed: 54 additions & 5 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1744,6 +1744,15 @@ def _normalize_tag(tag: str) -> str:
17441744
return tag[1:] if tag.startswith("v") else tag
17451745

17461746

1747+
def _canonicalize_version_text(value: str) -> str:
1748+
"""Normalize version-like text for equality checks when parseable."""
1749+
normalized = _normalize_tag(value)
1750+
try:
1751+
return str(Version(normalized))
1752+
except InvalidVersion:
1753+
return normalized
1754+
1755+
17471756
def _render_argv(argv: list[str]) -> str:
17481757
"""Render argv for copy/paste on the current platform."""
17491758
return subprocess.list2cmdline(argv) if os.name == "nt" else shlex.join(argv)
@@ -2677,15 +2686,20 @@ def self_upgrade(
26772686
raise typer.Exit(0)
26782687

26792688
# No-op success when the user is already on the latest tag.
2680-
latest_normalized = _normalize_tag(plan.target_tag)
2689+
target_canonical = _canonicalize_version_text(plan.target_tag)
2690+
current_canonical = (
2691+
_canonicalize_version_text(plan.current_version)
2692+
if plan.current_version != "unknown"
2693+
else "unknown"
2694+
)
26812695
if plan.current_version != "unknown":
2682-
if tag is None and not _is_newer(latest_normalized, plan.current_version):
2683-
if plan.current_version == latest_normalized:
2696+
if tag is None and not _is_newer(target_canonical, plan.current_version):
2697+
if current_canonical == target_canonical:
26842698
console.print(f"Already on latest release: {plan.target_tag}")
26852699
else:
26862700
console.print(f"Already on latest release or newer: {plan.current_version}")
26872701
raise typer.Exit(0)
2688-
if tag is not None and plan.current_version == latest_normalized:
2702+
if tag is not None and current_canonical == target_canonical:
26892703
console.print(f"Already on requested release: {plan.target_tag}")
26902704
raise typer.Exit(0)
26912705

@@ -2728,7 +2742,11 @@ def self_upgrade(
27282742
# pre-upgrade module, so importlib.metadata would lie. A fresh `specify
27292743
# --version` is the only signal that the new binary is actually live.
27302744
verified = _verify_upgrade(plan)
2731-
if verified is None or _normalize_tag(plan.target_tag) != verified:
2745+
if (
2746+
verified is None
2747+
or _canonicalize_version_text(plan.target_tag)
2748+
!= _canonicalize_version_text(verified)
2749+
):
27322750
_emit_failure(
27332751
"verification-mismatch",
27342752
plan=plan,

tests/test_self_upgrade.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,17 @@ def test_pinned_older_tag_still_runs_installer(
380380
assert "Upgrading specify-cli 0.7.6 → v0.7.5 via uv tool:" in out
381381
assert mock_run.call_count == 2
382382

383+
def test_pinned_rc_tag_uses_canonical_version_equality_for_noop(
384+
self, uv_tool_argv0, clean_environ
385+
):
386+
with patch("specify_cli.shutil.which", return_value="/usr/bin/uv"), patch(
387+
"specify_cli._get_installed_version", return_value="1.0.0rc1"
388+
):
389+
result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"])
390+
391+
assert result.exit_code == 0
392+
assert "Already on requested release: v1.0.0-rc1" in strip_ansi(result.output)
393+
383394

384395
class TestDryRun_UvTool:
385396
"""--dry-run preview path + --dry-run combined with --tag."""
@@ -1120,6 +1131,26 @@ def test_verify_nonzero_exit_is_not_treated_as_success(
11201131
assert "Verification failed" in out
11211132
assert "(unknown) (expected v0.7.6)" in out
11221133

1134+
def test_verify_accepts_pep440_equivalent_rc_version(
1135+
self,
1136+
uv_tool_argv0,
1137+
clean_environ,
1138+
):
1139+
with patch("specify_cli.urllib.request.urlopen") as mock_urlopen, patch(
1140+
"specify_cli.shutil.which", return_value="/usr/bin/uv"
1141+
), patch("specify_cli.subprocess.run") as mock_run, patch(
1142+
"specify_cli._get_installed_version", return_value="0.9.0"
1143+
):
1144+
mock_urlopen.return_value = _mock_urlopen_response({"tag_name": "v9.9.9"})
1145+
mock_run.side_effect = [
1146+
_completed_process(0),
1147+
_completed_process(0, stdout="specify 1.0.0rc1\n"),
1148+
]
1149+
result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"])
1150+
1151+
assert result.exit_code == 0
1152+
assert "Upgraded specify-cli: 0.9.0 → 1.0.0rc1" in strip_ansi(result.output)
1153+
11231154
def test_verify_uses_current_entrypoint_when_not_on_path(
11241155
self,
11251156
uv_tool_argv0,

0 commit comments

Comments
 (0)