arcade-mcp/libs/arcade-cli/arcade_cli/update.py
Eric Gustin d31a81ef3f
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>
2026-04-02 11:30:55 -07:00

319 lines
11 KiB
Python

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