Add background update check & notification for arcade CLI (#800)
## Summary - On every CLI command invocation (except `update`/`upgrade`/`mcp`), a detached subprocess checks PyPI for newer versions (throttled to once per 4 hours) and caches the result at `~/.arcade/update_cache.json` - On the next invocation, if a newer version is known, a yellow one-liner notification is printed suggesting `arcade update` - Respects `ARCADE_DISABLE_AUTOUPDATE=1` environment variable to opt out entirely <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds a background PyPI version check that spawns detached subprocesses and may print update notifications on most CLI invocations; mistakes could impact CLI reliability or corrupt MCP stdio output (mitigated by explicit command exclusions). > > **Overview** > Adds `arcade update` (and hidden `arcade upgrade` alias) to self-upgrade the `arcade-mcp` CLI by detecting the original install method (`uv tool`, `pipx`, `uv pip`, or `pip`) and running the appropriate upgrade command. > > Introduces a **throttled background update check** on most CLI invocations: a detached subprocess queries PyPI, writes `~/.arcade/update_cache.json`, and on subsequent runs prints a one-line notification when a newer version is cached; this is disabled via `ARCADE_DISABLE_AUTOUPDATE=1` and explicitly skipped for `update`/`upgrade`/`mcp` to avoid MCP stdio output corruption. > > Bumps the package version to `1.13.0`, adds a `packaging` dependency, and includes comprehensive tests covering PyPI/yanked/prerelease handling, install-method detection, caching, and callback integration. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2d9646ecc2211e8cfecd6e4901d14b1f5b7bb306. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7ce7d6892f
commit
d31a81ef3f
5 changed files with 979 additions and 2 deletions
|
|
@ -31,7 +31,7 @@ arcade-core (base: config, errors, catalog, telemetry)
|
|||
|
||||
## Versioning Rules
|
||||
|
||||
- Use semver. ALWAYS bump the version in `pyproject.toml` when modifying a library's code.
|
||||
- Use semver. Bump the version in `pyproject.toml` when modifying a library's code — but first check `git diff main` to see if the version has already been bumped in the current branch. Only bump once per branch/PR.
|
||||
- ALWAYS bump the minimum required dependency version when making breaking changes between libraries.
|
||||
|
||||
## Key Patterns
|
||||
|
|
@ -67,6 +67,7 @@ Transports: `stdio` (default) and `http` (tools that require auth or secrets nee
|
|||
|
||||
- **All changes must have tests and follow TDD.** Every new feature, bug fix, or behavioral change needs a corresponding test in `libs/tests/`.
|
||||
- **Always use uv.** Never use `pip`, `pip install`, `python`, or `python -m` directly. Use `uv run`, `uv sync`, `uv build`, etc.
|
||||
- **Never pollute stdout/stderr in MCP stdio paths.** Code reachable by `arcade-mcp-server` or the `arcade mcp` CLI command must never print, log to stdout, or spawn processes that write to stdout/stderr. The MCP stdio transport requires a clean JSON-only channel — any stray output corrupts the protocol. When adding CLI-wide hooks or notifications, always gate them to exclude MCP transport paths.
|
||||
|
||||
## Code Quality
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
|
@ -28,6 +29,7 @@ from arcade_cli.project import app as project_app
|
|||
from arcade_cli.secret import app as secret_app
|
||||
from arcade_cli.server import app as server_app
|
||||
from arcade_cli.show import show_logic
|
||||
from arcade_cli.update import check_and_notify, run_update
|
||||
from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup
|
||||
from arcade_cli.utils import (
|
||||
ModelSpec,
|
||||
|
|
@ -892,6 +894,30 @@ def deploy(
|
|||
handle_cli_error("Failed to deploy server", e, debug)
|
||||
|
||||
|
||||
@cli.command(help="Check for and install CLI updates", rich_help_panel="Manage")
|
||||
def update(
|
||||
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
|
||||
) -> None:
|
||||
"""Check for updates to the Arcade CLI and install if available."""
|
||||
try:
|
||||
run_update()
|
||||
except Exception as e:
|
||||
handle_cli_error("Failed to check for updates", e, debug)
|
||||
|
||||
|
||||
@cli.command(
|
||||
name="upgrade",
|
||||
help="Check for and install CLI updates (alias for update)",
|
||||
rich_help_panel="Manage",
|
||||
hidden=True,
|
||||
)
|
||||
def upgrade(
|
||||
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
|
||||
) -> None:
|
||||
"""Alias for `arcade update`."""
|
||||
update(debug=debug)
|
||||
|
||||
|
||||
@cli.command(help="Open the Arcade Dashboard in a web browser", rich_help_panel="User")
|
||||
def dashboard(
|
||||
host: str = typer.Option(
|
||||
|
|
@ -963,6 +989,12 @@ def main_callback(
|
|||
help="Print version and exit.",
|
||||
),
|
||||
) -> None:
|
||||
# Background update check + notification (skip for update/upgrade/mcp to avoid
|
||||
# corrupting MCP stdio protocol with non-JSON output)
|
||||
if ctx.invoked_subcommand not in {update.__name__, upgrade.__name__, mcp.__name__}:
|
||||
with contextlib.suppress(Exception):
|
||||
check_and_notify()
|
||||
|
||||
# Commands that do not require a logged in user
|
||||
public_commands = {
|
||||
login.__name__,
|
||||
|
|
@ -973,6 +1005,8 @@ def main_callback(
|
|||
new.__name__,
|
||||
show.__name__,
|
||||
configure.__name__,
|
||||
update.__name__,
|
||||
upgrade.__name__,
|
||||
}
|
||||
if ctx.invoked_subcommand in public_commands:
|
||||
return
|
||||
|
|
|
|||
319
libs/arcade-cli/arcade_cli/update.py
Normal file
319
libs/arcade-cli/arcade_cli/update.py
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
"""Logic for the `arcade update` / `arcade upgrade` CLI command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from importlib import metadata
|
||||
from urllib.request import urlopen
|
||||
|
||||
from arcade_core.constants import ARCADE_CONFIG_PATH
|
||||
from arcade_core.subprocess_utils import (
|
||||
build_windows_hidden_startupinfo,
|
||||
get_windows_no_window_creationflags,
|
||||
)
|
||||
from packaging.version import Version
|
||||
|
||||
from arcade_cli.console import console
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PACKAGE_NAME = "arcade-mcp"
|
||||
PYPI_URL = f"https://pypi.org/pypi/{PACKAGE_NAME}/json"
|
||||
# Pattern to match PACKAGE_NAME exactly (not as a prefix of e.g. "arcade-mcp-server")
|
||||
_PACKAGE_RE = re.compile(rf"(?:^|\s){re.escape(PACKAGE_NAME)}(?:\s|$)", re.MULTILINE)
|
||||
|
||||
UPDATE_CACHE_PATH = os.path.join(ARCADE_CONFIG_PATH, "update_cache.json")
|
||||
# Minimum interval between background PyPI version checks
|
||||
CHECK_INTERVAL_SECONDS = 4 * 60 * 60 # 4 hours
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Update cache dataclass and I/O
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateCache:
|
||||
latest_version: str
|
||||
checked_at: float # time.time()
|
||||
|
||||
|
||||
def read_update_cache(cache_path: str) -> UpdateCache | None:
|
||||
"""Read the update cache from disk. Returns None if missing or corrupt."""
|
||||
try:
|
||||
with open(cache_path) as f:
|
||||
data = json.load(f)
|
||||
# Only extract fields defined on UpdateCache so schema changes stay in sync
|
||||
fields = {f.name for f in dataclasses.fields(UpdateCache)}
|
||||
return UpdateCache(**{k: data[k] for k in fields})
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def write_update_cache(cache_path: str, cache: UpdateCache) -> None:
|
||||
"""Write the update cache to disk."""
|
||||
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
|
||||
with open(cache_path, "w") as f:
|
||||
json.dump(dataclasses.asdict(cache), f)
|
||||
|
||||
|
||||
def should_check_for_update(cache: UpdateCache | None) -> bool:
|
||||
"""Determine whether a background PyPI check is needed."""
|
||||
if os.environ.get("ARCADE_DISABLE_AUTOUPDATE") == "1":
|
||||
return False
|
||||
if cache is None:
|
||||
return True
|
||||
return (time.time() - cache.checked_at) > CHECK_INTERVAL_SECONDS
|
||||
|
||||
|
||||
def fork_background_check() -> None:
|
||||
"""Spawn a detached process that checks PyPI and writes the cache."""
|
||||
cache = read_update_cache(UPDATE_CACHE_PATH)
|
||||
if not should_check_for_update(cache):
|
||||
return
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-c",
|
||||
"from arcade_cli.update import _background_check; _background_check()",
|
||||
]
|
||||
with contextlib.suppress(Exception):
|
||||
if sys.platform == "win32":
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=get_windows_no_window_creationflags(new_process_group=True),
|
||||
startupinfo=build_windows_hidden_startupinfo(),
|
||||
close_fds=True,
|
||||
)
|
||||
else:
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
close_fds=True,
|
||||
)
|
||||
|
||||
|
||||
def _background_check() -> None:
|
||||
"""Entry point for the detached background process. Fetches PyPI, writes cache.
|
||||
|
||||
Always updates ``checked_at`` so that failed checks don't bypass the throttle
|
||||
and spawn a new background process on every CLI invocation.
|
||||
"""
|
||||
latest = fetch_latest_pypi_version()
|
||||
# Preserve the previously cached version when the fetch fails
|
||||
existing = read_update_cache(UPDATE_CACHE_PATH)
|
||||
cached_version = latest or (existing.latest_version if existing else "")
|
||||
write_update_cache(
|
||||
UPDATE_CACHE_PATH, UpdateCache(latest_version=cached_version, checked_at=time.time())
|
||||
)
|
||||
|
||||
|
||||
def check_and_notify() -> None:
|
||||
"""Read cache, print notification if update available, fork background check."""
|
||||
if os.environ.get("ARCADE_DISABLE_AUTOUPDATE") == "1":
|
||||
return
|
||||
cache = read_update_cache(UPDATE_CACHE_PATH)
|
||||
if cache:
|
||||
try:
|
||||
cached_version = Version(cache.latest_version)
|
||||
if cached_version > Version(metadata.version(PACKAGE_NAME)):
|
||||
console.print(
|
||||
f"Update available: {metadata.version(PACKAGE_NAME)} → {cache.latest_version} "
|
||||
f"Run `arcade update` to upgrade.",
|
||||
style="yellow",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to check cached update version", exc_info=True)
|
||||
fork_background_check()
|
||||
|
||||
|
||||
class InstallMethod(str, Enum):
|
||||
UV_TOOL = "uv_tool"
|
||||
PIPX = "pipx"
|
||||
UV_PIP = "uv_pip"
|
||||
PIP = "pip"
|
||||
|
||||
|
||||
def fetch_latest_pypi_version() -> str | None:
|
||||
"""Query PyPI for the latest stable version of the package.
|
||||
|
||||
Returns None only if the fetch fails or no stable version exists.
|
||||
If ``data["info"]["version"]`` is a stable, non-yanked release it is returned
|
||||
immediately. Otherwise falls back to scanning all releases for the highest
|
||||
stable, non-yanked version.
|
||||
"""
|
||||
try:
|
||||
with urlopen(PYPI_URL, timeout=5) as resp: # noqa: S310
|
||||
if resp.status != 200:
|
||||
return None
|
||||
data = json.loads(resp.read())
|
||||
latest: str = data["info"]["version"]
|
||||
releases = data.get("releases", {})
|
||||
latest_files = releases.get(latest, [])
|
||||
# Return immediately only if stable AND not yanked
|
||||
if (
|
||||
not Version(latest).is_prerelease
|
||||
and latest_files
|
||||
and not all(f.get("yanked", False) for f in latest_files)
|
||||
):
|
||||
return latest
|
||||
# Scan releases for the newest stable, non-yanked version
|
||||
releases = data.get("releases", {})
|
||||
stable_versions: list[Version] = []
|
||||
for v, files in releases.items():
|
||||
with contextlib.suppress(Exception):
|
||||
parsed = Version(v)
|
||||
if parsed.is_prerelease:
|
||||
continue
|
||||
# Skip yanked releases (all files marked yanked, or no files)
|
||||
if not files or all(f.get("yanked", False) for f in files):
|
||||
continue
|
||||
stable_versions.append(parsed)
|
||||
if not stable_versions:
|
||||
return None
|
||||
return str(max(stable_versions))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def detect_install_method() -> InstallMethod:
|
||||
"""Auto-detect how the user originally installed the CLI.
|
||||
|
||||
Detection order:
|
||||
1. uv tool — check ``uv tool list`` for the package
|
||||
2. pipx — check ``pipx list`` for the package
|
||||
3. uv pip — if ``uv`` is on PATH but the package wasn't installed via uv tool
|
||||
4. pip — fallback
|
||||
"""
|
||||
if shutil.which("uv"):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["uv", "tool", "list"], # noqa: S607
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0 and _PACKAGE_RE.search(result.stdout):
|
||||
return InstallMethod.UV_TOOL
|
||||
except Exception:
|
||||
logger.debug("Failed to check uv tool list", exc_info=True)
|
||||
|
||||
if shutil.which("pipx"):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pipx", "list"], # noqa: S607
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0 and _PACKAGE_RE.search(result.stdout):
|
||||
return InstallMethod.PIPX
|
||||
except Exception:
|
||||
logger.debug("Failed to check pipx list", exc_info=True)
|
||||
|
||||
if shutil.which("uv"):
|
||||
# Only use uv pip if the package is actually installed in the current
|
||||
# interpreter's environment. Without this check we might upgrade into a
|
||||
# different environment than the one backing the running ``arcade`` executable.
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["uv", "pip", "show", PACKAGE_NAME, "--python", sys.executable], # noqa: S607
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0 and PACKAGE_NAME in result.stdout:
|
||||
return InstallMethod.UV_PIP
|
||||
except Exception:
|
||||
logger.debug("Failed to check uv pip show", exc_info=True)
|
||||
|
||||
return InstallMethod.PIP
|
||||
|
||||
|
||||
_UPGRADE_COMMANDS: dict[InstallMethod, list[str]] = {
|
||||
InstallMethod.UV_TOOL: ["uv", "tool", "upgrade", PACKAGE_NAME],
|
||||
InstallMethod.PIPX: ["pipx", "upgrade", PACKAGE_NAME],
|
||||
InstallMethod.UV_PIP: [
|
||||
"uv",
|
||||
"pip",
|
||||
"install",
|
||||
"--upgrade",
|
||||
"--python",
|
||||
sys.executable,
|
||||
PACKAGE_NAME,
|
||||
],
|
||||
InstallMethod.PIP: ["pip", "install", "--upgrade", PACKAGE_NAME],
|
||||
}
|
||||
|
||||
_METHOD_LABELS: dict[InstallMethod, str] = {
|
||||
InstallMethod.UV_TOOL: "uv tool",
|
||||
InstallMethod.PIPX: "pipx",
|
||||
InstallMethod.UV_PIP: "uv pip",
|
||||
InstallMethod.PIP: "pip",
|
||||
}
|
||||
|
||||
|
||||
def run_update() -> None:
|
||||
"""Check for updates and install if available."""
|
||||
console.print("Checking for updates…")
|
||||
|
||||
latest = fetch_latest_pypi_version()
|
||||
if latest is None:
|
||||
console.print(
|
||||
"Could not check for updates. Verify your internet connection and try again.",
|
||||
style="yellow",
|
||||
)
|
||||
return
|
||||
|
||||
current = metadata.version(PACKAGE_NAME)
|
||||
|
||||
if Version(current) >= Version(latest):
|
||||
console.print(
|
||||
f"Arcade CLI is already up to date (version {current}).",
|
||||
style="bold green",
|
||||
)
|
||||
return
|
||||
|
||||
console.print(f"Update available: {current} → {latest}")
|
||||
|
||||
method = detect_install_method()
|
||||
cmd = _UPGRADE_COMMANDS[method]
|
||||
|
||||
# Warn if not using the recommended install method
|
||||
if method != InstallMethod.UV_TOOL:
|
||||
console.print(
|
||||
f"\n⚠️ Detected install method: {_METHOD_LABELS[method]}. "
|
||||
f"We recommend installing via `uv tool install {PACKAGE_NAME}` for the best experience.\n",
|
||||
style="yellow",
|
||||
)
|
||||
|
||||
console.print(f"Upgrading via: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd)
|
||||
|
||||
if result.returncode == 0:
|
||||
console.print(f"Successfully updated to {latest}!", style="bold green")
|
||||
else:
|
||||
console.print(
|
||||
f"\nAuto-upgrade failed. Try upgrading manually:\n"
|
||||
f" uv tool upgrade {PACKAGE_NAME}\n\n"
|
||||
f"If you don't use `uv tool`, try one of these alternatives instead:\n"
|
||||
f" uv pip install -U {PACKAGE_NAME}\n"
|
||||
f" pip install -U {PACKAGE_NAME}",
|
||||
style="red",
|
||||
)
|
||||
622
libs/tests/cli/test_update.py
Normal file
622
libs/tests/cli/test_update.py
Normal file
|
|
@ -0,0 +1,622 @@
|
|||
"""Tests for the `arcade update` CLI command."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from arcade_cli.main import cli
|
||||
from arcade_cli.update import (
|
||||
InstallMethod,
|
||||
UpdateCache,
|
||||
_background_check,
|
||||
check_and_notify,
|
||||
detect_install_method,
|
||||
fetch_latest_pypi_version,
|
||||
fork_background_check,
|
||||
read_update_cache,
|
||||
should_check_for_update,
|
||||
write_update_cache,
|
||||
)
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
PACKAGE_NAME = "arcade-mcp"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for fetch_latest_pypi_version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFetchLatestPypiVersion:
|
||||
def test_returns_version_on_success(self) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status = 200
|
||||
mock_response.read.return_value = json.dumps({
|
||||
"info": {"version": "2.0.0"},
|
||||
"releases": {"2.0.0": [{"filename": "a.whl", "yanked": False}]},
|
||||
}).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch("arcade_cli.update.urlopen", return_value=mock_response):
|
||||
assert fetch_latest_pypi_version() == "2.0.0"
|
||||
|
||||
def test_falls_back_to_stable_release_when_latest_is_prerelease(self) -> None:
|
||||
"""When info.version is a pre-release, return the highest stable from releases."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status = 200
|
||||
payload = json.dumps({
|
||||
"info": {"version": "2.0.0rc1"},
|
||||
"releases": {
|
||||
"1.9.0": [{"filename": "a.whl", "yanked": False}],
|
||||
"2.0.0rc1": [{"filename": "b.whl", "yanked": False}],
|
||||
"1.10.0": [{"filename": "c.whl", "yanked": False}],
|
||||
},
|
||||
}).encode()
|
||||
mock_response.read.return_value = payload
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch("arcade_cli.update.urlopen", return_value=mock_response):
|
||||
assert fetch_latest_pypi_version() == "1.10.0"
|
||||
|
||||
def test_returns_none_when_only_prereleases_exist(self) -> None:
|
||||
"""When all releases are pre-releases, return None."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status = 200
|
||||
payload = json.dumps({
|
||||
"info": {"version": "2.0.0rc1"},
|
||||
"releases": {"2.0.0a1": [], "2.0.0rc1": []},
|
||||
}).encode()
|
||||
mock_response.read.return_value = payload
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch("arcade_cli.update.urlopen", return_value=mock_response):
|
||||
assert fetch_latest_pypi_version() is None
|
||||
|
||||
def test_skips_yanked_releases(self) -> None:
|
||||
"""Yanked releases should not be returned even if they are the highest stable."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status = 200
|
||||
payload = json.dumps({
|
||||
"info": {"version": "2.0.0rc1"},
|
||||
"releases": {
|
||||
"1.9.0": [{"filename": "a.whl", "yanked": False}],
|
||||
"1.10.0": [{"filename": "b.whl", "yanked": True}],
|
||||
"2.0.0rc1": [{"filename": "c.whl", "yanked": False}],
|
||||
},
|
||||
}).encode()
|
||||
mock_response.read.return_value = payload
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch("arcade_cli.update.urlopen", return_value=mock_response):
|
||||
# 1.10.0 is yanked, so should fall back to 1.9.0
|
||||
assert fetch_latest_pypi_version() == "1.9.0"
|
||||
|
||||
def test_falls_back_when_latest_stable_is_yanked(self) -> None:
|
||||
"""When info.version is stable but yanked, fall back to scanning releases."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status = 200
|
||||
payload = json.dumps({
|
||||
"info": {"version": "2.0.0"},
|
||||
"releases": {
|
||||
"1.9.0": [{"filename": "a.whl", "yanked": False}],
|
||||
"2.0.0": [{"filename": "b.whl", "yanked": True}],
|
||||
},
|
||||
}).encode()
|
||||
mock_response.read.return_value = payload
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch("arcade_cli.update.urlopen", return_value=mock_response):
|
||||
# 2.0.0 is yanked, so should fall back to 1.9.0
|
||||
assert fetch_latest_pypi_version() == "1.9.0"
|
||||
|
||||
def test_returns_none_on_http_error(self) -> None:
|
||||
with patch("arcade_cli.update.urlopen", side_effect=Exception("network error")):
|
||||
assert fetch_latest_pypi_version() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for detect_install_method
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDetectInstallMethod:
|
||||
def test_detects_uv_tool(self) -> None:
|
||||
"""If `uv tool list` output contains the package name, method is UV_TOOL."""
|
||||
uv_tool_output = "arcade-mcp v1.12.1\n- arcade\n- arcade-mcp\nother-package v0.1.0\n"
|
||||
with patch("arcade_cli.update.shutil") as mock_shutil:
|
||||
mock_shutil.which.return_value = "/usr/local/bin/uv"
|
||||
with patch("arcade_cli.update.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=uv_tool_output)
|
||||
assert detect_install_method() == InstallMethod.UV_TOOL
|
||||
|
||||
def test_detects_pipx(self) -> None:
|
||||
"""If uv tool doesn't have it but pipx does, method is PIPX."""
|
||||
|
||||
def run_side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
|
||||
if cmd[0] == "uv":
|
||||
return MagicMock(returncode=0, stdout="other-package v1.0.0\n")
|
||||
if cmd[0] == "pipx":
|
||||
return MagicMock(returncode=0, stdout=f" package {PACKAGE_NAME} 1.12.1")
|
||||
return MagicMock(returncode=1, stdout="")
|
||||
|
||||
with patch("arcade_cli.update.shutil") as mock_shutil:
|
||||
mock_shutil.which.side_effect = lambda name: f"/usr/local/bin/{name}"
|
||||
with patch("arcade_cli.update.subprocess.run", side_effect=run_side_effect):
|
||||
assert detect_install_method() == InstallMethod.PIPX
|
||||
|
||||
def test_falls_back_to_uv_pip_when_uv_available(self) -> None:
|
||||
"""If uv is available but package not in uv tool or pipx, use UV_PIP."""
|
||||
|
||||
def run_side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
|
||||
# uv tool list / pipx list don't have it
|
||||
if "tool" in cmd:
|
||||
return MagicMock(returncode=0, stdout="other-package v1.0.0\n")
|
||||
# uv pip show confirms the package is in the current environment
|
||||
if "show" in cmd:
|
||||
return MagicMock(returncode=0, stdout=f"Name: {PACKAGE_NAME}\nVersion: 1.0.0\n")
|
||||
return MagicMock(returncode=0, stdout="other-package v1.0.0\n")
|
||||
|
||||
def which_side_effect(name: str) -> str | None:
|
||||
if name == "uv":
|
||||
return "/usr/local/bin/uv"
|
||||
return None # pipx not available
|
||||
|
||||
with patch("arcade_cli.update.shutil") as mock_shutil:
|
||||
mock_shutil.which.side_effect = which_side_effect
|
||||
with patch("arcade_cli.update.subprocess.run", side_effect=run_side_effect):
|
||||
assert detect_install_method() == InstallMethod.UV_PIP
|
||||
|
||||
def test_falls_back_to_pip_when_uv_pip_show_fails(self) -> None:
|
||||
"""If uv is on PATH but package is not in the current env, fall back to PIP."""
|
||||
|
||||
def run_side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
|
||||
if "tool" in cmd:
|
||||
return MagicMock(returncode=0, stdout="other-package v1.0.0\n")
|
||||
if "show" in cmd:
|
||||
return MagicMock(returncode=1, stdout="")
|
||||
return MagicMock(returncode=0, stdout="")
|
||||
|
||||
def which_side_effect(name: str) -> str | None:
|
||||
if name == "uv":
|
||||
return "/usr/local/bin/uv"
|
||||
return None
|
||||
|
||||
with patch("arcade_cli.update.shutil") as mock_shutil:
|
||||
mock_shutil.which.side_effect = which_side_effect
|
||||
with patch("arcade_cli.update.subprocess.run", side_effect=run_side_effect):
|
||||
assert detect_install_method() == InstallMethod.PIP
|
||||
|
||||
def test_does_not_false_positive_on_prefix_match(self) -> None:
|
||||
"""arcade-mcp-server should NOT be detected as arcade-mcp in uv tool list."""
|
||||
uv_tool_output = "arcade-mcp-server v1.0.0\n- arcade-mcp-server\n"
|
||||
|
||||
def run_side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
|
||||
if "tool" in cmd:
|
||||
return MagicMock(returncode=0, stdout=uv_tool_output)
|
||||
# uv pip show confirms the package IS in the current env
|
||||
if "show" in cmd:
|
||||
return MagicMock(returncode=0, stdout=f"Name: {PACKAGE_NAME}\nVersion: 1.0.0\n")
|
||||
return MagicMock(returncode=0, stdout="")
|
||||
|
||||
with patch("arcade_cli.update.shutil") as mock_shutil:
|
||||
mock_shutil.which.return_value = "/usr/local/bin/uv"
|
||||
with patch("arcade_cli.update.subprocess.run", side_effect=run_side_effect):
|
||||
# Should NOT detect UV_TOOL since only arcade-mcp-server is in uv tool list
|
||||
# but should detect UV_PIP since uv pip show confirms the package
|
||||
assert detect_install_method() == InstallMethod.UV_PIP
|
||||
|
||||
def test_falls_back_to_pip(self) -> None:
|
||||
"""If neither uv nor pipx is available, fall back to PIP."""
|
||||
with patch("arcade_cli.update.shutil") as mock_shutil:
|
||||
mock_shutil.which.return_value = None # nothing on PATH
|
||||
assert detect_install_method() == InstallMethod.PIP
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests for the `arcade update` CLI command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateCommand:
|
||||
def test_already_up_to_date(self) -> None:
|
||||
with (
|
||||
patch("arcade_cli.update.fetch_latest_pypi_version", return_value="1.12.1"),
|
||||
patch("arcade_cli.update.metadata") as mock_meta,
|
||||
):
|
||||
mock_meta.version.return_value = "1.12.1"
|
||||
result = runner.invoke(cli, ["update"])
|
||||
assert result.exit_code == 0
|
||||
assert "up to date" in result.output.lower()
|
||||
|
||||
def test_update_available_uv_tool(self) -> None:
|
||||
with (
|
||||
patch("arcade_cli.update.fetch_latest_pypi_version", return_value="2.0.0"),
|
||||
patch("arcade_cli.update.metadata") as mock_meta,
|
||||
patch("arcade_cli.update.detect_install_method", return_value=InstallMethod.UV_TOOL),
|
||||
patch("arcade_cli.update.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_meta.version.return_value = "1.12.1"
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(cli, ["update"])
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd == ["uv", "tool", "upgrade", PACKAGE_NAME]
|
||||
|
||||
def test_update_available_pipx_shows_warning(self) -> None:
|
||||
"""Non-uv-tool methods should show a warning recommending uv tool install."""
|
||||
with (
|
||||
patch("arcade_cli.update.fetch_latest_pypi_version", return_value="2.0.0"),
|
||||
patch("arcade_cli.update.metadata") as mock_meta,
|
||||
patch("arcade_cli.update.detect_install_method", return_value=InstallMethod.PIPX),
|
||||
patch("arcade_cli.update.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_meta.version.return_value = "1.12.1"
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(cli, ["update"])
|
||||
assert result.exit_code == 0
|
||||
assert "recommend" in result.output.lower()
|
||||
assert "uv tool" in result.output.lower()
|
||||
|
||||
def test_update_available_uv_pip_shows_warning(self) -> None:
|
||||
with (
|
||||
patch("arcade_cli.update.fetch_latest_pypi_version", return_value="2.0.0"),
|
||||
patch("arcade_cli.update.metadata") as mock_meta,
|
||||
patch("arcade_cli.update.detect_install_method", return_value=InstallMethod.UV_PIP),
|
||||
patch("arcade_cli.update.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_meta.version.return_value = "1.12.1"
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(cli, ["update"])
|
||||
assert result.exit_code == 0
|
||||
assert "recommend" in result.output.lower()
|
||||
assert "uv tool" in result.output.lower()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd == ["uv", "pip", "install", "--upgrade", "--python", sys.executable, PACKAGE_NAME]
|
||||
|
||||
def test_update_available_pip_shows_warning(self) -> None:
|
||||
with (
|
||||
patch("arcade_cli.update.fetch_latest_pypi_version", return_value="2.0.0"),
|
||||
patch("arcade_cli.update.metadata") as mock_meta,
|
||||
patch("arcade_cli.update.detect_install_method", return_value=InstallMethod.PIP),
|
||||
patch("arcade_cli.update.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_meta.version.return_value = "1.12.1"
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(cli, ["update"])
|
||||
assert result.exit_code == 0
|
||||
assert "recommend" in result.output.lower()
|
||||
assert "uv tool" in result.output.lower()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd == ["pip", "install", "--upgrade", PACKAGE_NAME]
|
||||
|
||||
def test_upgrade_alias_works(self) -> None:
|
||||
"""The `arcade upgrade` alias should behave identically."""
|
||||
with (
|
||||
patch("arcade_cli.update.fetch_latest_pypi_version", return_value="1.12.1"),
|
||||
patch("arcade_cli.update.metadata") as mock_meta,
|
||||
):
|
||||
mock_meta.version.return_value = "1.12.1"
|
||||
result = runner.invoke(cli, ["upgrade"])
|
||||
assert result.exit_code == 0
|
||||
assert "up to date" in result.output.lower()
|
||||
|
||||
def test_pypi_fetch_failure(self) -> None:
|
||||
with patch("arcade_cli.update.fetch_latest_pypi_version", return_value=None):
|
||||
result = runner.invoke(cli, ["update"])
|
||||
assert result.exit_code == 0
|
||||
assert "could not check" in result.output.lower()
|
||||
|
||||
def test_upgrade_command_failure_shows_manual_instructions(self) -> None:
|
||||
with (
|
||||
patch("arcade_cli.update.fetch_latest_pypi_version", return_value="2.0.0"),
|
||||
patch("arcade_cli.update.metadata") as mock_meta,
|
||||
patch("arcade_cli.update.detect_install_method", return_value=InstallMethod.UV_TOOL),
|
||||
patch("arcade_cli.update.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_meta.version.return_value = "1.12.1"
|
||||
mock_run.return_value = MagicMock(returncode=1)
|
||||
result = runner.invoke(cli, ["update"])
|
||||
assert result.exit_code == 0
|
||||
output = result.output.lower()
|
||||
# Should show preferred method and alternatives
|
||||
assert "uv tool upgrade" in output
|
||||
assert "alternatives" in output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for UpdateCache and cache I/O
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateCache:
|
||||
def test_read_cache_returns_none_when_file_missing(self, tmp_path: pytest.TempPathFactory) -> None:
|
||||
assert read_update_cache(str(tmp_path / "nonexistent.json")) is None
|
||||
|
||||
def test_read_cache_returns_none_on_corrupt_json(self, tmp_path: pytest.TempPathFactory) -> None:
|
||||
cache_file = tmp_path / "update_cache.json"
|
||||
cache_file.write_text("not valid json{{{")
|
||||
assert read_update_cache(str(cache_file)) is None
|
||||
|
||||
def test_write_and_read_cache_roundtrip(self, tmp_path: pytest.TempPathFactory) -> None:
|
||||
cache_file = str(tmp_path / "update_cache.json")
|
||||
cache = UpdateCache(latest_version="2.0.0", checked_at=1000.0)
|
||||
write_update_cache(cache_file, cache)
|
||||
result = read_update_cache(cache_file)
|
||||
assert result is not None
|
||||
assert result.latest_version == "2.0.0"
|
||||
assert result.checked_at == 1000.0
|
||||
|
||||
def test_read_cache_ignores_unknown_fields(self, tmp_path: pytest.TempPathFactory) -> None:
|
||||
cache_file = tmp_path / "update_cache.json"
|
||||
cache_file.write_text(json.dumps({
|
||||
"latest_version": "2.0.0",
|
||||
"checked_at": 1000.0,
|
||||
"unknown_field": "should be ignored",
|
||||
}))
|
||||
result = read_update_cache(str(cache_file))
|
||||
assert result is not None
|
||||
assert result.latest_version == "2.0.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for should_check_for_update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestShouldCheckForUpdate:
|
||||
def test_returns_true_when_no_cache(self) -> None:
|
||||
assert should_check_for_update(None) is True
|
||||
|
||||
def test_returns_true_when_cache_expired(self) -> None:
|
||||
old_cache = UpdateCache(latest_version="1.0.0", checked_at=time.time() - 5 * 3600)
|
||||
assert should_check_for_update(old_cache) is True
|
||||
|
||||
def test_returns_false_when_cache_fresh(self) -> None:
|
||||
fresh_cache = UpdateCache(latest_version="1.0.0", checked_at=time.time() - 60)
|
||||
assert should_check_for_update(fresh_cache) is False
|
||||
|
||||
def test_returns_false_when_disabled_env_var(self) -> None:
|
||||
with patch.dict(os.environ, {"ARCADE_DISABLE_AUTOUPDATE": "1"}):
|
||||
assert should_check_for_update(None) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for fork_background_check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestForkBackgroundCheck:
|
||||
def test_background_check_import_string_is_valid(self) -> None:
|
||||
"""The hardcoded import string in fork_background_check must resolve to the real function."""
|
||||
import importlib
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(fork_background_check)
|
||||
# Extract the import string from the "-c" argument
|
||||
assert "from arcade_cli.update import _background_check; _background_check()" in source
|
||||
# Verify the function is actually importable at that path
|
||||
mod = importlib.import_module("arcade_cli.update")
|
||||
assert hasattr(mod, "_background_check") and callable(mod._background_check)
|
||||
|
||||
def test_spawns_detached_subprocess_unix(self) -> None:
|
||||
"""On Unix, spawns a detached subprocess with start_new_session=True."""
|
||||
with (
|
||||
patch("arcade_cli.update.read_update_cache", return_value=None),
|
||||
patch("arcade_cli.update.should_check_for_update", return_value=True),
|
||||
patch("arcade_cli.update.sys") as mock_sys,
|
||||
patch("arcade_cli.update.subprocess.Popen") as mock_popen,
|
||||
):
|
||||
mock_sys.platform = "darwin"
|
||||
mock_sys.executable = "/usr/bin/python3"
|
||||
fork_background_check()
|
||||
mock_popen.assert_called_once()
|
||||
call_kwargs = mock_popen.call_args[1]
|
||||
assert call_kwargs.get("start_new_session") is True
|
||||
assert call_kwargs.get("close_fds") is True
|
||||
|
||||
def test_spawns_detached_subprocess_windows(self) -> None:
|
||||
"""On Windows, spawns a subprocess with creationflags instead of start_new_session."""
|
||||
with (
|
||||
patch("arcade_cli.update.read_update_cache", return_value=None),
|
||||
patch("arcade_cli.update.should_check_for_update", return_value=True),
|
||||
patch("arcade_cli.update.sys") as mock_sys,
|
||||
patch("arcade_cli.update.subprocess.Popen") as mock_popen,
|
||||
patch("arcade_cli.update.get_windows_no_window_creationflags", return_value=0x08000200),
|
||||
patch("arcade_cli.update.build_windows_hidden_startupinfo", return_value=None),
|
||||
):
|
||||
mock_sys.platform = "win32"
|
||||
mock_sys.executable = "python.exe"
|
||||
fork_background_check()
|
||||
mock_popen.assert_called_once()
|
||||
call_kwargs = mock_popen.call_args[1]
|
||||
assert "start_new_session" not in call_kwargs
|
||||
assert call_kwargs.get("creationflags") == 0x08000200
|
||||
assert call_kwargs.get("close_fds") is True
|
||||
|
||||
def test_does_not_spawn_when_check_not_needed(self) -> None:
|
||||
"""When cache is fresh, no subprocess is spawned."""
|
||||
fresh_cache = UpdateCache(latest_version="1.0.0", checked_at=time.time())
|
||||
with (
|
||||
patch("arcade_cli.update.read_update_cache", return_value=fresh_cache),
|
||||
patch("arcade_cli.update.should_check_for_update", return_value=False),
|
||||
patch("arcade_cli.update.subprocess.Popen") as mock_popen,
|
||||
):
|
||||
fork_background_check()
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
def test_swallows_exceptions(self) -> None:
|
||||
"""Popen raising an exception should not crash."""
|
||||
with (
|
||||
patch("arcade_cli.update.read_update_cache", return_value=None),
|
||||
patch("arcade_cli.update.should_check_for_update", return_value=True),
|
||||
patch("arcade_cli.update.subprocess.Popen", side_effect=OSError("spawn failed")),
|
||||
):
|
||||
# Should not raise
|
||||
fork_background_check()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for check_and_notify
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCheckAndNotify:
|
||||
def test_prints_notification_when_update_available(self) -> None:
|
||||
cache = UpdateCache(latest_version="2.0.0", checked_at=time.time())
|
||||
with (
|
||||
patch("arcade_cli.update.read_update_cache", return_value=cache),
|
||||
patch("arcade_cli.update.metadata") as mock_meta,
|
||||
patch("arcade_cli.update.fork_background_check"),
|
||||
patch("arcade_cli.update.console") as mock_console,
|
||||
):
|
||||
mock_meta.version.return_value = "1.0.0"
|
||||
check_and_notify()
|
||||
output = mock_console.print.call_args[0][0]
|
||||
assert "update available" in output.lower()
|
||||
assert "arcade update" in output.lower()
|
||||
assert mock_console.print.call_args[1]["style"] == "yellow"
|
||||
|
||||
def test_no_notification_when_up_to_date(self) -> None:
|
||||
cache = UpdateCache(latest_version="1.0.0", checked_at=time.time())
|
||||
with (
|
||||
patch("arcade_cli.update.read_update_cache", return_value=cache),
|
||||
patch("arcade_cli.update.metadata") as mock_meta,
|
||||
patch("arcade_cli.update.fork_background_check"),
|
||||
patch("arcade_cli.update.console") as mock_console,
|
||||
):
|
||||
mock_meta.version.return_value = "1.0.0"
|
||||
check_and_notify()
|
||||
mock_console.print.assert_not_called()
|
||||
|
||||
def test_no_notification_when_no_cache(self) -> None:
|
||||
with (
|
||||
patch("arcade_cli.update.read_update_cache", return_value=None),
|
||||
patch("arcade_cli.update.fork_background_check"),
|
||||
patch("arcade_cli.update.console") as mock_console,
|
||||
):
|
||||
check_and_notify()
|
||||
mock_console.print.assert_not_called()
|
||||
|
||||
def test_no_notification_when_disabled(self) -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {"ARCADE_DISABLE_AUTOUPDATE": "1"}),
|
||||
patch("arcade_cli.update.read_update_cache") as mock_read,
|
||||
patch("arcade_cli.update.fork_background_check") as mock_fork,
|
||||
patch("arcade_cli.update.console") as mock_console,
|
||||
):
|
||||
check_and_notify()
|
||||
mock_read.assert_not_called()
|
||||
mock_fork.assert_not_called()
|
||||
mock_console.print.assert_not_called()
|
||||
|
||||
def test_forks_background_check(self) -> None:
|
||||
with (
|
||||
patch("arcade_cli.update.read_update_cache", return_value=None),
|
||||
patch("arcade_cli.update.fork_background_check") as mock_fork,
|
||||
):
|
||||
check_and_notify()
|
||||
mock_fork.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for _background_check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBackgroundCheck:
|
||||
def test_updates_timestamp_when_fetch_returns_none(
|
||||
self, tmp_path: pytest.TempPathFactory
|
||||
) -> None:
|
||||
"""When fetch returns None, the cache timestamp is still updated to throttle retries."""
|
||||
cache_path = str(tmp_path / "update_cache.json")
|
||||
with (
|
||||
patch("arcade_cli.update.fetch_latest_pypi_version", return_value=None),
|
||||
patch("arcade_cli.update.UPDATE_CACHE_PATH", cache_path),
|
||||
):
|
||||
_background_check()
|
||||
result = read_update_cache(cache_path)
|
||||
assert result is not None
|
||||
assert result.latest_version == ""
|
||||
assert result.checked_at > 0
|
||||
|
||||
def test_preserves_cached_version_when_fetch_fails(
|
||||
self, tmp_path: pytest.TempPathFactory
|
||||
) -> None:
|
||||
"""When fetch fails but a previous version is cached, preserve it."""
|
||||
cache_path = str(tmp_path / "update_cache.json")
|
||||
old_cache = UpdateCache(latest_version="1.5.0", checked_at=1000.0)
|
||||
write_update_cache(cache_path, old_cache)
|
||||
with (
|
||||
patch("arcade_cli.update.fetch_latest_pypi_version", return_value=None),
|
||||
patch("arcade_cli.update.UPDATE_CACHE_PATH", cache_path),
|
||||
):
|
||||
_background_check()
|
||||
result = read_update_cache(cache_path)
|
||||
assert result is not None
|
||||
assert result.latest_version == "1.5.0"
|
||||
assert result.checked_at > old_cache.checked_at
|
||||
|
||||
def test_caches_stable_release(self, tmp_path: pytest.TempPathFactory) -> None:
|
||||
"""Stable versions from PyPI should be cached."""
|
||||
cache_path = str(tmp_path / "update_cache.json")
|
||||
with (
|
||||
patch("arcade_cli.update.fetch_latest_pypi_version", return_value="2.0.0"),
|
||||
patch("arcade_cli.update.UPDATE_CACHE_PATH", cache_path),
|
||||
):
|
||||
_background_check()
|
||||
result = read_update_cache(cache_path)
|
||||
assert result is not None
|
||||
assert result.latest_version == "2.0.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests for main_callback integration with check_and_notify
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMainCallbackUpdateNotification:
|
||||
def test_main_callback_calls_check_and_notify(self) -> None:
|
||||
"""Running a non-update command triggers check_and_notify."""
|
||||
with patch("arcade_cli.main.check_and_notify") as mock_notify:
|
||||
# Use 'show' as a public command that goes through main_callback
|
||||
runner.invoke(cli, ["show", "--help"])
|
||||
mock_notify.assert_called_once()
|
||||
|
||||
def test_main_callback_skips_check_for_update_command(self) -> None:
|
||||
"""Running `arcade update` should NOT trigger check_and_notify."""
|
||||
with (
|
||||
patch("arcade_cli.main.check_and_notify") as mock_notify,
|
||||
patch("arcade_cli.update.fetch_latest_pypi_version", return_value="1.0.0"),
|
||||
patch("arcade_cli.update.metadata") as mock_meta,
|
||||
):
|
||||
mock_meta.version.return_value = "1.0.0"
|
||||
runner.invoke(cli, ["update"])
|
||||
mock_notify.assert_not_called()
|
||||
|
||||
def test_main_callback_skips_check_for_upgrade_command(self) -> None:
|
||||
"""Running `arcade upgrade` should NOT trigger check_and_notify."""
|
||||
with (
|
||||
patch("arcade_cli.main.check_and_notify") as mock_notify,
|
||||
patch("arcade_cli.update.fetch_latest_pypi_version", return_value="1.0.0"),
|
||||
patch("arcade_cli.update.metadata") as mock_meta,
|
||||
):
|
||||
mock_meta.version.return_value = "1.0.0"
|
||||
runner.invoke(cli, ["upgrade"])
|
||||
mock_notify.assert_not_called()
|
||||
|
||||
def test_main_callback_skips_check_for_mcp_command(self) -> None:
|
||||
"""Running `arcade mcp` should NOT trigger check_and_notify (would corrupt stdio)."""
|
||||
with patch("arcade_cli.main.check_and_notify") as mock_notify:
|
||||
runner.invoke(cli, ["mcp", "--help"])
|
||||
mock_notify.assert_not_called()
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "arcade-mcp"
|
||||
version = "1.12.2"
|
||||
version = "1.13.0"
|
||||
description = "Arcade.dev - Tool Calling platform for Agents"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
|
|
@ -29,6 +29,7 @@ dependencies = [
|
|||
"tqdm==4.67.1",
|
||||
"click==8.1.8",
|
||||
"python-dateutil>=2.8.2",
|
||||
"packaging>=23.0",
|
||||
"platformdirs>=4.3.6; platform_system == 'Windows'",
|
||||
]
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue