mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
* feat(cli): implement specify self upgrade
* fix(cli): normalize self-upgrade prerelease tags
* fix(cli): tighten self-upgrade diagnostics
* fix(cli): harden self-upgrade verification parsing
* fix(cli): sanitize self-check fallback tags
* fix(cli): harden self-check release display
* fix(cli): validate resolved upgrade tags
* fix(cli): tolerate invalid install metadata
* test(cli): align upgrade network mocks
* fix(cli): respect relative installer paths
* fix(cli): tighten upgrade failure handling
* fix(cli): align installer path diagnostics
* fix(cli): validate release and version output
* fix(cli): clarify source checkout guidance
* fix(cli): harden upgrade detection helpers
* fix(cli): avoid echoing invalid release tags
* fix(cli): tolerate argv path resolve failures
* chore: remove self-upgrade formatting-only diffs
* fix: address self-upgrade review feedback
* fix: address self-upgrade review followups
* fix: address self-upgrade review edge cases
* fix: address self-upgrade review docs
* fix: refine self-upgrade review followups
* fix: address self-upgrade review cleanup
* fix: handle self-upgrade review edge cases
* fix: address self-upgrade review nits
* fix: address follow-up self-upgrade review
* fix: resolve self-upgrade review and Windows CI failures
- README: promote "Optional Commands" to ### so it is a sibling of
"Core Commands" under "Available Slash Commands" (consistent heading
levels; avoids the h2->h4 jump a revert would create).
- _version: allow --tag prerelease/dev and build-metadata suffixes to
compose (e.g. v1.0.0-rc1+build.42), matching PEP 440 / semver; the
Version() check still enforces canonical validity.
- tests: compare resolved argv0 as Path objects instead of POSIX strings
so the assertion holds on Windows; skip the relative-installer-path
executable-bit tests on Windows via a new requires_posix marker (they
rely on chmod/X_OK semantics and chdir-into-tmp teardown that do not
hold there). Add a combined prerelease+build-metadata tag test.
* fix: address second self-upgrade review round
- self_check: clarify that the "up to date" branch is reached only for
parseable latest tags (the unparseable case returns earlier), so the
InvalidVersion fallback assumption is not reintroduced.
- self_upgrade: compare target/current as Version instances directly
instead of re-parsing the canonical strings through _is_newer; the
empty-current case stays explicit via the not-None guard.
- tests: document the intentional broad GH_/GITHUB_ env scrub with a test
asserting non-credential context vars (GH_HOST, GITHUB_REPOSITORY, …) are
stripped from the installer subprocess env — a deliberate fail-safe that
also catches credential-adjacent names without a recognized suffix.
* fix: address third self-upgrade review round
- self_upgrade: unify the no-op short-circuits on packaging Version
equality instead of canonical-string equality. Version("1.0") equals
Version("1.0.0") but their str() forms differ, so the old check could
misreport an equal install as "already on latest release or newer".
Both the unpinned and pinned branches now use Version comparison.
- self_upgrade: compare the verified version as a parsed Version against
the target so a non-version verifier result is a mismatch (exit 2)
rather than a coincidental canonical-string match.
- resolver: map HTTP 429 (Too Many Requests / secondary rate limit) to
the rate-limited category so users get the same actionable token hint
as 403.
- _is_github_credential_env_key: document the precise (intentionally
broad) scrub matching contract in the docstring.
- tests: add a trailing-zero Version-equality regression test and a
parametrized HTTP-status categorization test (429 -> rate limited;
404/502 -> verbatim).
* fix: address fourth self-upgrade review round
- self_upgrade: label a pinned target older than the installed version as
"Downgrading" rather than "Upgrading" so `--tag <older>` is not mistaken
for a forward upgrade.
- resolver: drop the unused `typing.Optional` import and annotate the
`--tag` option as `str | None`, consistent with the rest of the module
(verified Typer resolves it on the supported Python versions).
- _is_github_credential_env_key: add `_PASSWORD` and `_CREDENTIALS` to the
recognized credential suffixes and document that only these shapes are
scrubbed (not blanket coverage).
- tests: assert the precise exit code (1) for the re-raised transient
OSError path; skip the InvalidMetadataError test on Pythons where the
real exception is absent instead of fabricating it; update the pinned
downgrade test to expect the "Downgrading" label.
* fix: accept uppercase V prefix in --tag
Fold a leading uppercase `V` (a common paste) to the canonical lowercase
`v` before validating `--tag`. The remainder of the tag stays
case-sensitive on purpose: the validated value is used verbatim as a git
ref, which is case-sensitive on GitHub, so rewriting label/build-metadata
casing could point at a tag that does not exist. Adds a normalization test.
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
|