diff --git a/CLAUDE.md b/CLAUDE.md index d723ffbd..0602c4a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/libs/arcade-cli/arcade_cli/main.py b/libs/arcade-cli/arcade_cli/main.py index 9b934e82..9d7add7a 100644 --- a/libs/arcade-cli/arcade_cli/main.py +++ b/libs/arcade-cli/arcade_cli/main.py @@ -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 diff --git a/libs/arcade-cli/arcade_cli/update.py b/libs/arcade-cli/arcade_cli/update.py new file mode 100644 index 00000000..f07f9f40 --- /dev/null +++ b/libs/arcade-cli/arcade_cli/update.py @@ -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", + ) diff --git a/libs/tests/cli/test_update.py b/libs/tests/cli/test_update.py new file mode 100644 index 00000000..06017a7a --- /dev/null +++ b/libs/tests/cli/test_update.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 46fb9c01..87c7d0e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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'", ]