888 lines
37 KiB
Python
888 lines
37 KiB
Python
"""Detection, argv assembly, and dry-run tests for `specify self upgrade`."""
|
|
|
|
import importlib.metadata
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
import specify_cli
|
|
from specify_cli import app
|
|
|
|
from tests.self_upgrade_helpers import (
|
|
_InstallMethod,
|
|
_assemble_installer_argv,
|
|
_completed_process,
|
|
_detect_install_method,
|
|
mock_urlopen_response,
|
|
runner,
|
|
strip_ansi,
|
|
)
|
|
|
|
|
|
class TestDetectionUvTool:
|
|
"""Tier-1 path-prefix detection for uv-tool installs."""
|
|
|
|
def test_posix_uv_tool_prefix_matches(self, uv_tool_argv0):
|
|
method, signals = _detect_install_method(include_signals=True)
|
|
assert method == _InstallMethod.UV_TOOL
|
|
assert signals.matched_tier == 1
|
|
assert "uv/tools/specify-cli" in signals.matched_prefix.replace("\\", "/")
|
|
|
|
def test_detection_is_deterministic(self, uv_tool_argv0):
|
|
a = _detect_install_method()
|
|
b = _detect_install_method()
|
|
assert a == b == _InstallMethod.UV_TOOL
|
|
|
|
def test_no_argv_match_falls_through_to_unsupported(self, unsupported_argv0):
|
|
with patch("specify_cli._version.shutil.which", return_value=None), patch(
|
|
"specify_cli._version._editable_marker_seen", return_value=False
|
|
):
|
|
method = _detect_install_method()
|
|
assert method == _InstallMethod.UNSUPPORTED
|
|
|
|
def test_include_signals_false_returns_bare_enum(self, uv_tool_argv0):
|
|
result = _detect_install_method(include_signals=False)
|
|
assert isinstance(result, _InstallMethod)
|
|
|
|
def test_bare_argv0_is_resolved_via_path_lookup(self, monkeypatch, tmp_path):
|
|
if os.name == "nt":
|
|
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
|
fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin"
|
|
else:
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
fake_dir = (
|
|
tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin"
|
|
)
|
|
fake_dir.mkdir(parents=True)
|
|
fake_specify = fake_dir / "specify"
|
|
fake_specify.write_text("#!/usr/bin/env python\n")
|
|
monkeypatch.setattr("sys.argv", ["specify"])
|
|
with patch(
|
|
"specify_cli._version.shutil.which",
|
|
side_effect=lambda name: str(fake_specify) if name == "specify" else None,
|
|
):
|
|
method = _detect_install_method()
|
|
assert method == _InstallMethod.UV_TOOL
|
|
|
|
def test_prefix_match_does_not_accept_sibling_directory(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
fake_dir = tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli2" / "bin"
|
|
fake_dir.mkdir(parents=True)
|
|
fake_specify = fake_dir / "specify"
|
|
fake_specify.write_text("#!/usr/bin/env python\n")
|
|
monkeypatch.setattr("sys.argv", [str(fake_specify)])
|
|
with patch("specify_cli._version.shutil.which", return_value=None), patch(
|
|
"specify_cli._version._editable_marker_seen", return_value=False
|
|
):
|
|
method = _detect_install_method()
|
|
assert method == _InstallMethod.UNSUPPORTED
|
|
|
|
def test_tier3_uv_tool_when_registry_lists_exact_name(
|
|
self,
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")])
|
|
|
|
def fake_which(name):
|
|
return "uv" if name == "uv" else None
|
|
|
|
def fake_run(argv, *args, **kwargs):
|
|
if argv[:3] == ["uv", "tool", "list"]:
|
|
return subprocess.CompletedProcess(
|
|
args=argv,
|
|
returncode=0,
|
|
stdout="specify-cli v0.7.6\nother-tool v1.2.3\n",
|
|
stderr="",
|
|
)
|
|
return subprocess.CompletedProcess(
|
|
args=argv, returncode=1, stdout="", stderr=""
|
|
)
|
|
|
|
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
|
"specify_cli._version.subprocess.run", side_effect=fake_run
|
|
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
|
method, signals = _detect_install_method(include_signals=True)
|
|
assert method == _InstallMethod.UV_TOOL
|
|
assert signals.matched_tier == 3
|
|
assert "uv tool list" in signals.installer_registries_consulted
|
|
|
|
def test_unresolved_bare_argv0_skips_tier3_registry_detection(self, monkeypatch):
|
|
monkeypatch.setattr("sys.argv", ["specify"])
|
|
|
|
def fake_which(name):
|
|
return "uv" if name == "uv" else None
|
|
|
|
def fake_run(argv, *args, **kwargs):
|
|
return subprocess.CompletedProcess(
|
|
args=argv,
|
|
returncode=0,
|
|
stdout="specify-cli v0.7.6\n",
|
|
stderr="",
|
|
)
|
|
|
|
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
|
"specify_cli._version.subprocess.run", side_effect=fake_run
|
|
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
|
method, signals = _detect_install_method(include_signals=True)
|
|
assert method == _InstallMethod.UNSUPPORTED
|
|
assert signals.installer_registries_consulted == ()
|
|
|
|
def test_bare_argv0_missing_path_resolution_allows_tier3_registry_detection(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
missing_specify = tmp_path / "missing" / "specify"
|
|
monkeypatch.setattr("sys.argv", ["specify"])
|
|
|
|
def fake_which(name):
|
|
if name == "specify":
|
|
return str(missing_specify)
|
|
if name == "uv":
|
|
return "uv"
|
|
return None
|
|
|
|
def fake_run(argv, *args, **kwargs):
|
|
if argv[:3] == ["uv", "tool", "list"]:
|
|
return subprocess.CompletedProcess(
|
|
args=argv,
|
|
returncode=0,
|
|
stdout="specify-cli v0.7.6\n",
|
|
stderr="",
|
|
)
|
|
return subprocess.CompletedProcess(
|
|
args=argv, returncode=1, stdout="", stderr=""
|
|
)
|
|
|
|
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
|
"specify_cli._version.subprocess.run", side_effect=fake_run
|
|
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
|
method, signals = _detect_install_method(include_signals=True)
|
|
|
|
assert method == _InstallMethod.UV_TOOL
|
|
assert signals.matched_tier == 3
|
|
assert "uv tool list" in signals.installer_registries_consulted
|
|
|
|
def test_missing_relative_argv0_falls_back_to_entrypoint_name_lookup(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
if os.name == "nt":
|
|
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
|
fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin"
|
|
else:
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
fake_dir = (
|
|
tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin"
|
|
)
|
|
fake_dir.mkdir(parents=True)
|
|
fake_specify = fake_dir / "specify"
|
|
fake_specify.write_text("#!/usr/bin/env python\n")
|
|
monkeypatch.setattr("sys.argv", ["./bin/specify"])
|
|
|
|
def fake_which(name):
|
|
return str(fake_specify) if name == "specify" else None
|
|
|
|
with patch("specify_cli._version.shutil.which", side_effect=fake_which):
|
|
method = _detect_install_method()
|
|
|
|
assert method == _InstallMethod.UV_TOOL
|
|
|
|
def test_tier3_uv_tool_ignores_substring_false_positive(
|
|
self,
|
|
unsupported_argv0,
|
|
):
|
|
def fake_which(name):
|
|
return "uv" if name == "uv" else None
|
|
|
|
def fake_run(argv, *args, **kwargs):
|
|
if argv[:3] == ["uv", "tool", "list"]:
|
|
return subprocess.CompletedProcess(
|
|
args=argv,
|
|
returncode=0,
|
|
stdout="my-specify-cli-helper v0.1.0\n",
|
|
stderr="",
|
|
)
|
|
return subprocess.CompletedProcess(
|
|
args=argv, returncode=1, stdout="", stderr=""
|
|
)
|
|
|
|
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
|
"specify_cli._version.subprocess.run", side_effect=fake_run
|
|
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
|
method = _detect_install_method()
|
|
assert method == _InstallMethod.UNSUPPORTED
|
|
|
|
def test_tier3_uv_tool_does_not_override_absolute_unsupported_entrypoint(
|
|
self,
|
|
unsupported_argv0,
|
|
):
|
|
def fake_which(name):
|
|
return "uv" if name == "uv" else None
|
|
|
|
def fake_run(argv, *args, **kwargs):
|
|
if argv[:3] == ["uv", "tool", "list"]:
|
|
return subprocess.CompletedProcess(
|
|
args=argv,
|
|
returncode=0,
|
|
stdout="specify-cli v0.7.6\n",
|
|
stderr="",
|
|
)
|
|
return subprocess.CompletedProcess(
|
|
args=argv, returncode=1, stdout="", stderr=""
|
|
)
|
|
|
|
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
|
"specify_cli._version.subprocess.run", side_effect=fake_run
|
|
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
|
method = _detect_install_method()
|
|
assert method == _InstallMethod.UNSUPPORTED
|
|
|
|
def test_tier3_uv_tool_does_not_override_resolved_bare_unsupported_entrypoint(
|
|
self,
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
venv_bin = tmp_path / "venv" / "bin"
|
|
venv_bin.mkdir(parents=True)
|
|
fake_specify = venv_bin / "specify"
|
|
fake_specify.write_text("#!/usr/bin/env python\n")
|
|
fake_specify.chmod(0o755)
|
|
monkeypatch.setattr("sys.argv", ["specify"])
|
|
|
|
def fake_which(name):
|
|
if name == "specify":
|
|
return str(fake_specify)
|
|
if name == "uv":
|
|
return "uv"
|
|
return None
|
|
|
|
def fake_run(argv, *args, **kwargs):
|
|
if argv[:3] == ["uv", "tool", "list"]:
|
|
return subprocess.CompletedProcess(
|
|
args=argv,
|
|
returncode=0,
|
|
stdout="specify-cli v0.7.6\n",
|
|
stderr="",
|
|
)
|
|
return subprocess.CompletedProcess(
|
|
args=argv, returncode=1, stdout="", stderr=""
|
|
)
|
|
|
|
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
|
"specify_cli._version.subprocess.run", side_effect=fake_run
|
|
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
|
method, signals = _detect_install_method(include_signals=True)
|
|
assert method == _InstallMethod.UNSUPPORTED
|
|
assert signals.matched_tier is None
|
|
assert signals.installer_registries_consulted == ()
|
|
|
|
|
|
class TestPrefixExpansion:
|
|
"""Path-prefix expansion edge cases."""
|
|
|
|
def test_literal_dollar_without_variable_name_is_preserved(self, tmp_path):
|
|
prefix_path = tmp_path / "specify-$-cache" / "tools" / "specify-cli"
|
|
prefix = str(prefix_path)
|
|
|
|
expanded = specify_cli._version._expand_prefix(prefix)
|
|
|
|
assert expanded == prefix_path.resolve()
|
|
|
|
def test_unresolved_posix_variable_is_rejected(self):
|
|
assert specify_cli._version._expand_prefix("$SPECIFY_MISSING/specify-cli/") is None
|
|
|
|
def test_absolute_prefix_resolve_oserror_is_rejected(self, tmp_path):
|
|
prefix = str(tmp_path / "specify-cli")
|
|
|
|
with patch("pathlib.Path.resolve", side_effect=OSError("bad path")):
|
|
assert specify_cli._version._expand_prefix(prefix) is None
|
|
|
|
|
|
class TestArgv0Resolution:
|
|
"""Entrypoint path resolution edge cases."""
|
|
|
|
def test_absolute_argv0_resolve_oserror_returns_original_path(self, tmp_path):
|
|
argv0 = tmp_path / "specify"
|
|
|
|
with patch("pathlib.Path.resolve", side_effect=OSError("bad path")):
|
|
assert specify_cli._version._resolved_argv0_path(str(argv0)) == argv0
|
|
|
|
def test_path_lookup_resolve_oserror_returns_unresolved_lookup_path(self):
|
|
with patch(
|
|
"specify_cli._version.shutil.which", return_value="/broken/specify"
|
|
), patch("pathlib.Path.resolve", side_effect=OSError("bad path")):
|
|
result = specify_cli._version._resolved_argv0_path("specify")
|
|
|
|
# Compare as Path objects: on Windows the same logical path renders
|
|
# with backslashes, so a raw string compare against the POSIX form
|
|
# would spuriously fail.
|
|
assert result == Path("/broken/specify")
|
|
|
|
|
|
class TestArgvAssemblyUvTool:
|
|
"""uv-tool installer argv shape."""
|
|
|
|
def test_stable_tag_produces_expected_argv(self):
|
|
with patch("specify_cli._version.shutil.which", return_value="uv"):
|
|
argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6")
|
|
assert argv == [
|
|
"uv",
|
|
"tool",
|
|
"install",
|
|
"specify-cli",
|
|
"--force",
|
|
"--from",
|
|
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
|
]
|
|
|
|
def test_dev_suffix_tag_embedded_literally(self):
|
|
with patch("specify_cli._version.shutil.which", return_value="uv"):
|
|
argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.8.0.dev0")
|
|
assert "git+https://github.com/github/spec-kit.git@v0.8.0.dev0" in argv
|
|
assert (
|
|
"upgrade" not in argv
|
|
) # never `uv tool upgrade` — does not accept --tag pinning
|
|
|
|
def test_missing_uv_returns_no_installer_argv(self):
|
|
with patch("specify_cli._version.shutil.which", return_value=None):
|
|
assert _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6") is None
|
|
|
|
|
|
class TestBareUpgradeUvTool:
|
|
"""uv-tool happy path, bare invocation."""
|
|
|
|
def test_happy_path_end_to_end(self, uv_tool_argv0, clean_environ):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
|
):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
mock_run.side_effect = [
|
|
_completed_process(0), # installer
|
|
_completed_process(0, stdout="specify 0.7.6\n"), # verify
|
|
]
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert result.exit_code == 0
|
|
out = strip_ansi(result.output)
|
|
assert "Upgrading specify-cli 0.7.5 → v0.7.6 via uv tool:" in out
|
|
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in out
|
|
assert mock_run.call_count == 2
|
|
for call in mock_run.call_args_list:
|
|
assert call.kwargs.get("shell", False) is False
|
|
|
|
def test_one_user_action_no_prompt(self, uv_tool_argv0, clean_environ):
|
|
# The single `invoke` represents the single user action — no prompt.
|
|
# If a prompt existed, runner.invoke would hang waiting for input.
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
|
):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
mock_run.side_effect = [
|
|
_completed_process(0),
|
|
_completed_process(0, stdout="specify 0.7.6\n"),
|
|
]
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
assert result.exit_code == 0
|
|
|
|
|
|
class TestAlreadyLatestUvTool:
|
|
"""already on latest, no installer launched."""
|
|
|
|
def test_already_latest_exits_zero_no_subprocess(
|
|
self, uv_tool_argv0, clean_environ
|
|
):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.subprocess.run"
|
|
) as mock_run, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version._get_installed_version", return_value="0.7.6"):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Already on latest release: v0.7.6" in strip_ansi(result.output)
|
|
assert mock_run.call_count == 0
|
|
|
|
def test_trailing_zero_equivalent_version_reports_latest_not_newer(
|
|
self, uv_tool_argv0, clean_environ
|
|
):
|
|
# Version("1.0") == Version("1.0.0") under packaging even though their
|
|
# canonical strings differ. The no-op message must use Version equality
|
|
# so this prints "Already on latest release", not "... or newer".
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.subprocess.run"
|
|
) as mock_run, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version._get_installed_version", return_value="1.0"):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.0.0"})
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert result.exit_code == 0
|
|
out = strip_ansi(result.output)
|
|
assert "Already on latest release: v1.0.0" in out
|
|
assert "or newer" not in out
|
|
assert mock_run.call_count == 0
|
|
|
|
def test_dev_build_ahead_of_release_reports_newer_noop(
|
|
self, uv_tool_argv0, clean_environ
|
|
):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.subprocess.run"
|
|
) as mock_run, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version._get_installed_version", return_value="0.7.7.dev0"):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Already on latest release or newer: 0.7.7.dev0" in strip_ansi(result.output)
|
|
assert mock_run.call_count == 0
|
|
|
|
def test_unparseable_current_version_does_not_false_noop(
|
|
self, uv_tool_argv0, clean_environ
|
|
):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.subprocess.run"
|
|
) as mock_run, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version._get_installed_version", return_value="release-main"):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
mock_run.side_effect = [
|
|
_completed_process(0),
|
|
_completed_process(0, stdout="specify 0.7.6\n"),
|
|
]
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert result.exit_code == 0
|
|
out = strip_ansi(result.output)
|
|
assert "Already on latest release" not in out
|
|
assert "Upgrading specify-cli release-main → v0.7.6 via uv tool:" in out
|
|
assert mock_run.call_count == 2
|
|
|
|
def test_unparseable_resolved_target_fails_before_literal_noop(
|
|
self, uv_tool_argv0, clean_environ
|
|
):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.subprocess.run"
|
|
) as mock_run, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version._get_installed_version", return_value="release-main"):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"})
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert result.exit_code == 1
|
|
out = strip_ansi(result.output)
|
|
assert "not a comparable version" in out
|
|
assert "release-main" not in out
|
|
assert "Already on latest release" not in out
|
|
assert mock_run.call_count == 0
|
|
|
|
def test_pinned_older_tag_still_runs_installer(
|
|
self, uv_tool_argv0, clean_environ
|
|
):
|
|
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
|
"specify_cli._version.subprocess.run"
|
|
) as mock_run, patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.6"
|
|
):
|
|
mock_run.side_effect = [
|
|
_completed_process(0),
|
|
_completed_process(0, stdout="specify 0.7.5\n"),
|
|
]
|
|
result = runner.invoke(app, ["self", "upgrade", "--tag", "v0.7.5"])
|
|
|
|
assert result.exit_code == 0
|
|
out = strip_ansi(result.output)
|
|
assert "Already on latest release" not in out
|
|
# A pinned older tag is a downgrade and must be labelled as such.
|
|
assert "Downgrading specify-cli 0.7.6 → v0.7.5 via uv tool:" in out
|
|
assert "Upgrading specify-cli" not in out
|
|
assert mock_run.call_count == 2
|
|
|
|
def test_pinned_rc_tag_uses_canonical_version_equality_for_noop(
|
|
self, uv_tool_argv0, clean_environ
|
|
):
|
|
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
|
"specify_cli._version._get_installed_version", return_value="1.0.0rc1"
|
|
):
|
|
result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Already on requested release: v1.0.0-rc1" in strip_ansi(result.output)
|
|
|
|
|
|
class TestDryRunUvTool:
|
|
"""--dry-run preview path + --dry-run combined with --tag."""
|
|
|
|
def test_dry_run_without_tag_resolves_network_but_no_subprocess(
|
|
self,
|
|
uv_tool_argv0,
|
|
clean_environ,
|
|
):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.subprocess.run"
|
|
) as mock_run, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
|
|
|
|
assert result.exit_code == 0
|
|
out = strip_ansi(result.output)
|
|
assert "Dry run — no changes will be made." in out
|
|
assert "Detected install method: uv tool" in out
|
|
assert "Current version: 0.7.5" in out
|
|
assert "Target version: v0.7.6" in out
|
|
assert "Command that would be executed:" in out
|
|
assert mock_run.call_count == 0
|
|
|
|
def test_dry_run_with_tag_skips_network(self, uv_tool_argv0, clean_environ):
|
|
# --dry-run with --tag must NOT hit the network.
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.subprocess.run"
|
|
), patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
|
):
|
|
result = runner.invoke(
|
|
app,
|
|
["self", "upgrade", "--dry-run", "--tag", "v0.8.0"],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "Target version: v0.8.0" in strip_ansi(result.output)
|
|
mock_urlopen.assert_not_called()
|
|
|
|
def test_dry_run_rejects_unparseable_network_tag_before_preview(
|
|
self, uv_tool_argv0, clean_environ
|
|
):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.subprocess.run"
|
|
) as mock_run, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
|
mock_urlopen.return_value = mock_urlopen_response(
|
|
{"tag_name": "v0.9.0;echo unsafe"}
|
|
)
|
|
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
|
|
|
|
out = strip_ansi(result.output)
|
|
assert result.exit_code == 1
|
|
assert "not a comparable version" in out
|
|
assert "v0.9.0;echo unsafe" not in out
|
|
assert "Command that would be executed:" not in out
|
|
assert mock_run.call_count == 0
|
|
|
|
def test_dry_run_with_missing_uv_flags_unresolved_installer(
|
|
self, uv_tool_argv0, clean_environ
|
|
):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.subprocess.run"
|
|
) as mock_run, patch(
|
|
"specify_cli._version.shutil.which", return_value=None
|
|
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
|
|
|
|
assert result.exit_code == 0
|
|
out = strip_ansi(result.output)
|
|
assert "Command that would be executed: (installer uv not found on PATH)" in out
|
|
assert "uv tool install" not in out
|
|
assert mock_run.call_count == 0
|
|
|
|
|
|
# ===========================================================================
|
|
# Phase 4 — User Story 2: `pipx` immediate upgrade (P2)
|
|
# ===========================================================================
|
|
|
|
|
|
class TestDetectionPipx:
|
|
"""Pipx detection — tier 1 (path) and tier 3 (registry)."""
|
|
|
|
def test_posix_pipx_prefix_matches(self, pipx_argv0):
|
|
method, signals = _detect_install_method(include_signals=True)
|
|
assert method == _InstallMethod.PIPX
|
|
assert signals.matched_tier == 1
|
|
|
|
def test_tier3_pipx_when_no_prefix_match_but_registry_lists_it(
|
|
self,
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")])
|
|
|
|
def fake_which(name):
|
|
return "pipx" if name == "pipx" else None
|
|
|
|
def fake_run(argv, *args, **kwargs):
|
|
if argv[:3] == ["pipx", "list", "--json"]:
|
|
return subprocess.CompletedProcess(
|
|
args=argv,
|
|
returncode=0,
|
|
stdout='{"venvs":{"specify-cli":{}}}',
|
|
stderr="",
|
|
)
|
|
return subprocess.CompletedProcess(
|
|
args=argv, returncode=1, stdout="", stderr=""
|
|
)
|
|
|
|
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
|
"specify_cli._version.subprocess.run", side_effect=fake_run
|
|
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
|
method, signals = _detect_install_method(include_signals=True)
|
|
assert method == _InstallMethod.PIPX
|
|
assert signals.matched_tier == 3
|
|
assert "pipx list --json" in signals.installer_registries_consulted
|
|
|
|
def test_tier3_pipx_does_not_override_absolute_unsupported_entrypoint(
|
|
self,
|
|
unsupported_argv0,
|
|
):
|
|
def fake_which(name):
|
|
return "pipx" if name == "pipx" else None
|
|
|
|
def fake_run(argv, *args, **kwargs):
|
|
if argv[:3] == ["pipx", "list", "--json"]:
|
|
return subprocess.CompletedProcess(
|
|
args=argv,
|
|
returncode=0,
|
|
stdout='{"venvs":{"specify-cli":{}}}',
|
|
stderr="",
|
|
)
|
|
return subprocess.CompletedProcess(
|
|
args=argv, returncode=1, stdout="", stderr=""
|
|
)
|
|
|
|
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
|
"specify_cli._version.subprocess.run", side_effect=fake_run
|
|
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
|
method = _detect_install_method()
|
|
assert method == _InstallMethod.UNSUPPORTED
|
|
|
|
def test_tier3_pipx_ignores_malformed_json_output(
|
|
self,
|
|
unsupported_argv0,
|
|
):
|
|
def fake_which(name):
|
|
return "pipx" if name == "pipx" else None
|
|
|
|
def fake_run(argv, *args, **kwargs):
|
|
if argv[:3] == ["pipx", "list", "--json"]:
|
|
return subprocess.CompletedProcess(
|
|
args=argv,
|
|
returncode=0,
|
|
stdout="not json but mentions specify-cli",
|
|
stderr="",
|
|
)
|
|
return subprocess.CompletedProcess(
|
|
args=argv, returncode=1, stdout="", stderr=""
|
|
)
|
|
|
|
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
|
"specify_cli._version.subprocess.run", side_effect=fake_run
|
|
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
|
method = _detect_install_method()
|
|
assert method == _InstallMethod.UNSUPPORTED
|
|
|
|
def test_tier3_both_uv_tool_and_pipx_match_is_treated_as_unsupported(
|
|
self,
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")])
|
|
|
|
def fake_which(name):
|
|
if name == "uv":
|
|
return "uv"
|
|
if name == "pipx":
|
|
return "pipx"
|
|
return None
|
|
|
|
def fake_run(argv, *args, **kwargs):
|
|
if argv[:3] == ["uv", "tool", "list"]:
|
|
return subprocess.CompletedProcess(
|
|
args=argv,
|
|
returncode=0,
|
|
stdout="specify-cli v0.7.6\n",
|
|
stderr="",
|
|
)
|
|
if argv[:3] == ["pipx", "list", "--json"]:
|
|
return subprocess.CompletedProcess(
|
|
args=argv,
|
|
returncode=0,
|
|
stdout='{"venvs":{"specify-cli":{}}}',
|
|
stderr="",
|
|
)
|
|
return subprocess.CompletedProcess(
|
|
args=argv, returncode=1, stdout="", stderr=""
|
|
)
|
|
|
|
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
|
"specify_cli._version.subprocess.run", side_effect=fake_run
|
|
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
|
method, signals = _detect_install_method(include_signals=True)
|
|
assert method == _InstallMethod.UNSUPPORTED
|
|
assert signals.matched_tier is None
|
|
assert "uv tool list" in signals.installer_registries_consulted
|
|
assert "pipx list --json" in signals.installer_registries_consulted
|
|
|
|
|
|
class TestEditableInstallMetadata:
|
|
@pytest.mark.skipif(
|
|
not hasattr(importlib.metadata, "InvalidMetadataError"),
|
|
reason=(
|
|
"importlib.metadata.InvalidMetadataError does not exist on this "
|
|
"Python; _editable_direct_url_path only catches it when present, so "
|
|
"fabricating it would exercise a path that cannot fire in production"
|
|
),
|
|
)
|
|
def test_editable_marker_false_when_metadata_is_invalid(self):
|
|
invalid_metadata_error = importlib.metadata.InvalidMetadataError
|
|
|
|
with patch(
|
|
"importlib.metadata.distribution",
|
|
side_effect=invalid_metadata_error("bad metadata"),
|
|
):
|
|
assert specify_cli._version._editable_marker_seen() is False
|
|
assert specify_cli._version._source_checkout_path() is None
|
|
|
|
def test_direct_url_editable_install_marks_source_checkout(self, tmp_path):
|
|
project_root = tmp_path / "spec-kit"
|
|
project_root.mkdir()
|
|
(project_root / ".git").mkdir()
|
|
|
|
class FakeDist:
|
|
files = []
|
|
|
|
def read_text(self, name):
|
|
if name == "direct_url.json":
|
|
return json.dumps(
|
|
{
|
|
"dir_info": {"editable": True},
|
|
"url": project_root.as_uri(),
|
|
}
|
|
)
|
|
return None
|
|
|
|
def locate_file(self, file):
|
|
return file
|
|
|
|
with patch("importlib.metadata.distribution", return_value=FakeDist()):
|
|
assert specify_cli._version._editable_marker_seen() is True
|
|
assert specify_cli._version._source_checkout_path() == project_root.resolve()
|
|
|
|
def test_editable_marker_false_without_explicit_editable_metadata(self, tmp_path):
|
|
repo_root = tmp_path / "repo"
|
|
repo_root.mkdir()
|
|
(repo_root / ".git").mkdir()
|
|
venv_file = repo_root / ".venv" / "lib" / "python3.13" / "site-packages" / "specify_cli.py"
|
|
venv_file.parent.mkdir(parents=True)
|
|
venv_file.write_text("# installed module\n")
|
|
|
|
class FakeDist:
|
|
files = ["specify_cli.py"]
|
|
|
|
def read_text(self, name):
|
|
return None
|
|
|
|
def locate_file(self, file):
|
|
return venv_file
|
|
|
|
with patch("importlib.metadata.distribution", return_value=FakeDist()):
|
|
assert specify_cli._version._editable_marker_seen() is False
|
|
|
|
|
|
class TestTagValidationWhitespace:
|
|
def test_tag_whitespace_is_trimmed_before_validation(self, uv_tool_argv0, clean_environ):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
|
):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v9.9.9"})
|
|
mock_run.side_effect = [
|
|
_completed_process(0),
|
|
_completed_process(0, stdout="specify 0.8.0\n"),
|
|
]
|
|
result = runner.invoke(app, ["self", "upgrade", "--tag", " v0.8.0 "])
|
|
|
|
assert result.exit_code == 0
|
|
assert "v0.8.0" in strip_ansi(result.output)
|
|
|
|
|
|
class TestArgvAssemblyPipx:
|
|
"""pipx installer argv shape — pipx 1.5+ uses positional PACKAGE_SPEC, never `--spec` or `upgrade`."""
|
|
|
|
def test_pipx_argv_uses_install_force_positional_not_upgrade(self):
|
|
with patch("specify_cli._version.shutil.which", return_value="pipx"):
|
|
argv = _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6")
|
|
assert argv == [
|
|
"pipx",
|
|
"install",
|
|
"--force",
|
|
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
|
]
|
|
assert "upgrade" not in argv # pipx upgrade does not accept arbitrary refs
|
|
assert "--spec" not in argv # pipx 1.5+ dropped the --spec flag
|
|
|
|
def test_missing_pipx_returns_no_installer_argv(self):
|
|
with patch("specify_cli._version.shutil.which", return_value=None):
|
|
assert _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6") is None
|
|
|
|
|
|
class TestBareUpgradePipx:
|
|
"""pipx happy path."""
|
|
|
|
def test_happy_path(self, pipx_argv0, clean_environ):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.shutil.which", return_value="pipx"
|
|
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
|
):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
mock_run.side_effect = [
|
|
_completed_process(0),
|
|
_completed_process(0, stdout="specify 0.7.6\n"),
|
|
]
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert result.exit_code == 0
|
|
out = strip_ansi(result.output)
|
|
assert "via pipx:" in out
|
|
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in out
|
|
|
|
|
|
class TestDetectionShortCircuit:
|
|
"""Tier-1 path-prefix matches short-circuit before registry checks."""
|
|
|
|
def test_pipx_argv0_prefix_short_circuits_before_registry_checks(
|
|
self,
|
|
pipx_argv0,
|
|
clean_environ,
|
|
):
|
|
with patch("specify_cli._version.shutil.which", return_value="/usr/bin/X"), patch(
|
|
"specify_cli._version.subprocess.run"
|
|
) as mock_run:
|
|
method = _detect_install_method()
|
|
assert method == _InstallMethod.PIPX
|
|
mock_run.assert_not_called()
|
|
|
|
|
|
class TestDryRunPipx:
|
|
def test_dry_run_preview_names_pipx(self, pipx_argv0, clean_environ):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.shutil.which", return_value="pipx"
|
|
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
|
):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
|
|
assert result.exit_code == 0
|
|
assert "Detected install method: pipx" in strip_ansi(result.output)
|
|
assert mock_run.call_count == 0
|