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:
Eric Gustin 2026-04-02 11:30:55 -07:00 committed by GitHub
parent 7ce7d6892f
commit d31a81ef3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 979 additions and 2 deletions

View file

@ -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

View file

@ -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

View 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",
)

View 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()

View file

@ -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'",
]