[TOO-326] Windows papercuts (#768)
<!-- CURSOR_SUMMARY --> > [!NOTE] > **Medium Risk** > Touches authentication/login flow, credentials-file permissions, and subprocess lifecycle behavior across platforms; while mostly defensive, regressions could impact login or process management on Windows/macOS runners. > > **Overview** > Improves Windows/cross-platform reliability across the CLI and MCP server: OAuth login now binds the callback server to `127.0.0.1`, avoids slow loopback reverse-DNS, adds a configurable callback timeout (`--timeout` + env default), and opens URLs via a Windows-friendly `_open_browser` to avoid flashing console windows. > > Centralizes CLI output via a shared `console` that forces UTF-8 on Windows, standardizes UTF-8 file reads/writes throughout, tightens credentials-file permissions on Windows using `icacls`, and adds shared Windows subprocess helpers for **no-window** process creation and graceful termination (used by `deploy`, MCP reload, and usage-tracking worker). > > Updates client configuration UX/robustness (Windows AppData resolution via `platformdirs`, Cursor config path fallbacks + compatibility writes, overwrite warnings, absolute `uv` path for GUI clients, safer path display) and improves `deploy` child-process handling to avoid pipe-buffer deadlocks while giving better debug-aware error messages. > > Expands CI to run tests on Linux/Windows/macOS, adds a no-auth CLI integration workflow, disables usage tracking in toolkits CI, and adds extensive regression tests for Windows signals, subprocess cleanup, UTF-8, and config-path edge cases; bumps `arcade-core` to `4.4.2` and `arcade-mcp-server` to `1.17.2` (with updated dependency pin). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0fabd8ca1cd647039ba6ddbdf3f7809c330bab9e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
This commit is contained in:
parent
9baab7fc07
commit
fe8ddfd500
63 changed files with 4544 additions and 309 deletions
39
.github/workflows/cli-integration-no-auth.yml
vendored
Normal file
39
.github/workflows/cli-integration-no-auth.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
name: CLI Integration (No Auth)
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cli-integration-no-auth:
|
||||||
|
name: CLI Integration No-Auth (${{ matrix.os }}, Python ${{ matrix.python-version }})
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
timeout-minutes: 35
|
||||||
|
env:
|
||||||
|
ARCADE_USAGE_TRACKING: "0"
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
|
python-version: ["3.10", "3.11", "3.12"]
|
||||||
|
fail-fast: false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up environment
|
||||||
|
uses: ./.github/actions/setup-uv-env
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- name: Install arcade-mcp from source
|
||||||
|
run: |
|
||||||
|
uv pip install -e .
|
||||||
|
|
||||||
|
- name: Run no-auth CLI smoke checks
|
||||||
|
run: |
|
||||||
|
uv run python tests/integration/no_auth_cli_smoke.py
|
||||||
8
.github/workflows/main.yml
vendored
8
.github/workflows/main.yml
vendored
|
|
@ -26,9 +26,11 @@ jobs:
|
||||||
run: make check
|
run: make check
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ${{ matrix.os }}
|
||||||
|
timeout-minutes: 25
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
python-version: ["3.10", "3.11", "3.12"]
|
python-version: ["3.10", "3.11", "3.12"]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -41,9 +43,9 @@ jobs:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Test libs
|
- name: Test libs
|
||||||
run: make test
|
run: uv run pytest -W ignore -v libs/tests --cov=libs --cov-config=pyproject.toml --cov-report=xml
|
||||||
- name: Upload coverage reports to Codecov with GitHub Action on Python 3.10
|
- name: Upload coverage reports to Codecov with GitHub Action on Python 3.10
|
||||||
uses: codecov/codecov-action@v4.0.1
|
uses: codecov/codecov-action@v4.0.1
|
||||||
if: ${{ matrix.python-version == '3.10' }}
|
if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.10' }}
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
|
||||||
15
.github/workflows/test-install.yml
vendored
15
.github/workflows/test-install.yml
vendored
|
|
@ -21,27 +21,18 @@ jobs:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install uv
|
- name: Set up environment
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: ./.github/actions/setup-uv-env
|
||||||
with:
|
|
||||||
version: "latest"
|
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
uv sync --extra all --extra dev
|
|
||||||
|
|
||||||
- name: Install arcade-mcp from source
|
- name: Install arcade-mcp from source
|
||||||
run: |
|
run: |
|
||||||
uv pip install -e .
|
uv pip install -e .
|
||||||
|
|
||||||
- name: Run installation test
|
- name: Run installation test
|
||||||
run: |
|
run: |
|
||||||
python tests/install/test_install.py
|
uv run pytest tests/install/ -v --tb=short
|
||||||
|
|
||||||
- name: Verify arcade CLI is available
|
- name: Verify arcade CLI is available
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
13
.github/workflows/test-toolkits.yml
vendored
13
.github/workflows/test-toolkits.yml
vendored
|
|
@ -7,6 +7,9 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened, ready_for_review]
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
|
|
||||||
|
env:
|
||||||
|
ARCADE_USAGE_TRACKING: "0"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
setup:
|
setup:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -32,11 +35,13 @@ jobs:
|
||||||
|
|
||||||
test-toolkits:
|
test-toolkits:
|
||||||
needs: setup
|
needs: setup
|
||||||
runs-on: ubuntu-latest
|
name: test-toolkits (${{ matrix.toolkit }}, ${{ matrix.os }})
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
toolkit: ${{ fromJson(needs.setup.outputs.toolkits_without_gha_secrets) }}
|
toolkit: ${{ fromJson(needs.setup.outputs.toolkits_without_gha_secrets) }}
|
||||||
fail-fast: true
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
@ -49,16 +54,19 @@ jobs:
|
||||||
|
|
||||||
- name: Install toolkit dependencies
|
- name: Install toolkit dependencies
|
||||||
working-directory: toolkits/${{ matrix.toolkit }}
|
working-directory: toolkits/${{ matrix.toolkit }}
|
||||||
|
shell: bash
|
||||||
run: uv pip install -e ".[dev]"
|
run: uv pip install -e ".[dev]"
|
||||||
|
|
||||||
- name: Check toolkit
|
- name: Check toolkit
|
||||||
working-directory: toolkits/${{ matrix.toolkit }}
|
working-directory: toolkits/${{ matrix.toolkit }}
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
uv run --active pre-commit run -a
|
uv run --active pre-commit run -a
|
||||||
uv run --active mypy --config-file=pyproject.toml
|
uv run --active mypy --config-file=pyproject.toml
|
||||||
|
|
||||||
- name: Test stand-alone toolkits (no secrets)
|
- name: Test stand-alone toolkits (no secrets)
|
||||||
working-directory: toolkits/${{ matrix.toolkit }}
|
working-directory: toolkits/${{ matrix.toolkit }}
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
# Run pytest and capture exit code
|
# Run pytest and capture exit code
|
||||||
uv run --active pytest -W ignore -v --cov=arcade_${{ matrix.toolkit }} --cov-report=xml || EXIT_CODE=$?
|
uv run --active pytest -W ignore -v --cov=arcade_${{ matrix.toolkit }} --cov-report=xml || EXIT_CODE=$?
|
||||||
|
|
@ -72,6 +80,7 @@ jobs:
|
||||||
|
|
||||||
test-toolkits-with-gha-secrets:
|
test-toolkits-with-gha-secrets:
|
||||||
needs: setup
|
needs: setup
|
||||||
|
# Linux-only: these toolkits bootstrap local DBs via docker/apt in tests/test_setup.sh.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,6 @@ repos:
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
args: [--fix]
|
args: [--fix]
|
||||||
exclude: "(.*/templates/.*|libs/tests/.*)"
|
exclude: "(.*/templates/.*|libs/tests/.*|_scratch/.*)"
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
exclude: "(.*/templates/.*|libs/tests/.*)"
|
exclude: "(.*/templates/.*|libs/tests/.*|_scratch/.*)"
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ uv venv --python 3.11.6
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install all packages and development dependencies via uv workspace
|
# Install all packages and development dependencies via uv workspace
|
||||||
uv sync --extra all --dev
|
uv sync --extra all --extra dev
|
||||||
|
|
||||||
# Install pre-commit hooks for code quality
|
# Install pre-commit hooks for code quality
|
||||||
uv run pre-commit install
|
uv run pre-commit install
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,12 @@ Implements OAuth 2.0 Authorization Code flow with PKCE for secure CLI authentica
|
||||||
Uses authlib for OAuth protocol handling.
|
Uses authlib for OAuth protocol handling.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import socketserver
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
|
@ -29,10 +33,14 @@ from arcade_core.auth_tokens import (
|
||||||
)
|
)
|
||||||
from arcade_core.config_model import AuthConfig, Config, ContextConfig, UserConfig
|
from arcade_core.config_model import AuthConfig, Config, ContextConfig, UserConfig
|
||||||
from arcade_core.constants import ARCADE_CONFIG_PATH, CREDENTIALS_FILE_PATH
|
from arcade_core.constants import ARCADE_CONFIG_PATH, CREDENTIALS_FILE_PATH
|
||||||
|
from arcade_core.subprocess_utils import build_windows_hidden_startupinfo
|
||||||
from authlib.integrations.httpx_client import OAuth2Client
|
from authlib.integrations.httpx_client import OAuth2Client
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
from pydantic import AliasChoices, BaseModel, Field
|
from pydantic import AliasChoices, BaseModel, Field
|
||||||
from rich.console import Console
|
|
||||||
|
from arcade_cli.console import console
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Set up Jinja2 templates
|
# Set up Jinja2 templates
|
||||||
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||||
|
|
@ -45,11 +53,26 @@ def _render_template(template_name: str, **context: Any) -> bytes:
|
||||||
return template.render(**context).encode("utf-8")
|
return template.render(**context).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
console = Console()
|
|
||||||
|
|
||||||
# OAuth constants
|
# OAuth constants
|
||||||
DEFAULT_SCOPES = "openid offline_access"
|
DEFAULT_SCOPES = "openid offline_access"
|
||||||
|
LOCAL_CALLBACK_HOST = "127.0.0.1"
|
||||||
LOCAL_CALLBACK_PORT = 9905
|
LOCAL_CALLBACK_PORT = 9905
|
||||||
|
_DEFAULT_OAUTH_TIMEOUT_FALLBACK_SECONDS = 600
|
||||||
|
|
||||||
|
|
||||||
|
def _get_default_oauth_timeout_seconds() -> int:
|
||||||
|
value = os.environ.get(
|
||||||
|
"ARCADE_LOGIN_TIMEOUT_SECONDS", str(_DEFAULT_OAUTH_TIMEOUT_FALLBACK_SECONDS)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except ValueError:
|
||||||
|
return _DEFAULT_OAUTH_TIMEOUT_FALLBACK_SECONDS
|
||||||
|
else:
|
||||||
|
return parsed if parsed > 0 else _DEFAULT_OAUTH_TIMEOUT_FALLBACK_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_OAUTH_TIMEOUT_SECONDS = _get_default_oauth_timeout_seconds()
|
||||||
|
|
||||||
|
|
||||||
def create_oauth_client(cli_config: CLIConfig) -> OAuth2Client: # type: ignore[no-any-unimported]
|
def create_oauth_client(cli_config: CLIConfig) -> OAuth2Client: # type: ignore[no-any-unimported]
|
||||||
|
|
@ -280,12 +303,42 @@ def fetch_projects(coordinator_url: str, org_id: str) -> list[ProjectInfo]:
|
||||||
return [ProjectInfo.model_validate(item) for item in data.get("data", {}).get("items", [])]
|
return [ProjectInfo.model_validate(item) for item in data.get("data", {}).get("items", [])]
|
||||||
|
|
||||||
|
|
||||||
|
class _LoopbackHTTPServer(HTTPServer):
|
||||||
|
"""HTTPServer that skips the potentially slow ``getfqdn()`` reverse-DNS
|
||||||
|
lookup in ``server_bind()``.
|
||||||
|
|
||||||
|
``HTTPServer.server_bind()`` calls ``socket.getfqdn(host)`` which invokes
|
||||||
|
``gethostbyaddr("127.0.0.1")`` via the system resolver. On macOS CI
|
||||||
|
runners (Apple Silicon / macOS 14) the mDNSResponder can take 5-30 s to
|
||||||
|
resolve the loopback PTR record when the DNS cache is cold, causing the
|
||||||
|
daemon thread to block inside the constructor and ``ready_event`` to never
|
||||||
|
fire within the timeout window.
|
||||||
|
|
||||||
|
We only listen on ``127.0.0.1`` for the OAuth callback, so we hard-set
|
||||||
|
``server_name`` to ``"127.0.0.1"`` and skip the DNS round-trip entirely.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def server_bind(self) -> None:
|
||||||
|
socketserver.TCPServer.server_bind(self)
|
||||||
|
host, port = self.server_address[:2]
|
||||||
|
self.server_name = host
|
||||||
|
self.server_port = port
|
||||||
|
|
||||||
|
|
||||||
class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
||||||
"""HTTP request handler for OAuth callback."""
|
"""HTTP request handler for OAuth callback."""
|
||||||
|
|
||||||
def __init__(self, *args, state: str, result_holder: dict, **kwargs): # type: ignore[no-untyped-def]
|
def __init__(
|
||||||
|
self,
|
||||||
|
*args: Any,
|
||||||
|
state: str,
|
||||||
|
result_holder: dict,
|
||||||
|
result_event: threading.Event,
|
||||||
|
**kwargs: Any,
|
||||||
|
):
|
||||||
self.state = state
|
self.state = state
|
||||||
self.result_holder = result_holder
|
self.result_holder = result_holder
|
||||||
|
self.result_event = result_event
|
||||||
# Store error details for template rendering
|
# Store error details for template rendering
|
||||||
self._error: str | None = None
|
self._error: str | None = None
|
||||||
self._error_description: str | None = None
|
self._error_description: str | None = None
|
||||||
|
|
@ -331,6 +384,7 @@ class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
||||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(_render_template("cli_login_success.jinja"))
|
self.wfile.write(_render_template("cli_login_success.jinja"))
|
||||||
|
self.result_event.set()
|
||||||
threading.Thread(target=self.server.shutdown).start()
|
threading.Thread(target=self.server.shutdown).start()
|
||||||
|
|
||||||
def _send_error_response(self, message: str | None = None) -> None:
|
def _send_error_response(self, message: str | None = None) -> None:
|
||||||
|
|
@ -346,6 +400,7 @@ class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
||||||
state=self._returned_state,
|
state=self._returned_state,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self.result_event.set()
|
||||||
threading.Thread(target=self.server.shutdown).start()
|
threading.Thread(target=self.server.shutdown).start()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -358,13 +413,34 @@ class OAuthCallbackServer:
|
||||||
self.httpd: HTTPServer | None = None
|
self.httpd: HTTPServer | None = None
|
||||||
self.result: dict[str, Any] = {}
|
self.result: dict[str, Any] = {}
|
||||||
|
|
||||||
|
# Threading events used on *all* platforms (not Windows-specific).
|
||||||
|
# result_event: signalled by the HTTP handler once the OAuth callback
|
||||||
|
# has been processed (success or error). Callers block on this via
|
||||||
|
# wait_for_result() instead of polling.
|
||||||
|
# ready_event: signalled by run_server() once the HTTPServer is bound
|
||||||
|
# and listening. Callers block on this via wait_until_ready() so
|
||||||
|
# they don't race the browser redirect against server startup.
|
||||||
|
self.result_event = threading.Event()
|
||||||
|
self.ready_event = threading.Event()
|
||||||
|
|
||||||
def run_server(self) -> None:
|
def run_server(self) -> None:
|
||||||
"""Start the callback server."""
|
"""Start the callback server.
|
||||||
server_address = ("", self.port)
|
|
||||||
|
Binds to 127.0.0.1 (loopback only) rather than 0.0.0.0 to avoid
|
||||||
|
Windows Firewall prompts and keep the redirect URI host aligned
|
||||||
|
with the actual bind host.
|
||||||
|
"""
|
||||||
|
server_address = (LOCAL_CALLBACK_HOST, self.port)
|
||||||
handler = lambda *args, **kwargs: OAuthCallbackHandler(
|
handler = lambda *args, **kwargs: OAuthCallbackHandler(
|
||||||
*args, state=self.state, result_holder=self.result, **kwargs
|
*args,
|
||||||
|
state=self.state,
|
||||||
|
result_holder=self.result,
|
||||||
|
result_event=self.result_event,
|
||||||
|
**kwargs,
|
||||||
)
|
)
|
||||||
self.httpd = HTTPServer(server_address, handler)
|
self.httpd = _LoopbackHTTPServer(server_address, handler)
|
||||||
|
self.port = self.httpd.server_port
|
||||||
|
self.ready_event.set()
|
||||||
self.httpd.serve_forever()
|
self.httpd.serve_forever()
|
||||||
|
|
||||||
def shutdown_server(self) -> None:
|
def shutdown_server(self) -> None:
|
||||||
|
|
@ -372,9 +448,27 @@ class OAuthCallbackServer:
|
||||||
if self.httpd:
|
if self.httpd:
|
||||||
self.httpd.shutdown()
|
self.httpd.shutdown()
|
||||||
|
|
||||||
|
def wait_until_ready(self, timeout: float | None = 2.0) -> bool:
|
||||||
|
"""Wait for the server to start listening."""
|
||||||
|
return self.ready_event.wait(timeout=timeout)
|
||||||
|
|
||||||
|
def wait_for_result(self, timeout: float | None) -> bool:
|
||||||
|
"""Wait for the OAuth callback to complete."""
|
||||||
|
if self.result_event.wait(timeout=timeout):
|
||||||
|
return True
|
||||||
|
|
||||||
|
timeout_desc = f"{int(timeout)}s" if timeout else "the configured timeout"
|
||||||
|
self.result["error"] = (
|
||||||
|
f"Timed out waiting for the login callback after {timeout_desc}. "
|
||||||
|
"If your browser completed login, check firewall/antivirus settings "
|
||||||
|
"and re-run 'arcade login' (you can increase --timeout if needed)."
|
||||||
|
)
|
||||||
|
self.shutdown_server()
|
||||||
|
return False
|
||||||
|
|
||||||
def get_redirect_uri(self) -> str:
|
def get_redirect_uri(self) -> str:
|
||||||
"""Get the redirect URI for this server."""
|
"""Get the redirect URI for this server."""
|
||||||
return f"http://localhost:{self.port}/callback"
|
return f"http://{LOCAL_CALLBACK_HOST}:{self.port}/callback"
|
||||||
|
|
||||||
|
|
||||||
def save_credentials_from_whoami(
|
def save_credentials_from_whoami(
|
||||||
|
|
@ -449,6 +543,79 @@ def get_active_context() -> tuple[str, str]:
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _open_browser(url: str) -> bool:
|
||||||
|
"""Open a URL in the default browser without flashing a CMD window on Windows.
|
||||||
|
|
||||||
|
On Windows, both ``webbrowser.open`` and ``os.startfile`` call
|
||||||
|
``ShellExecuteW`` under the hood which can briefly flash a console window
|
||||||
|
depending on how the default-browser handler is registered.
|
||||||
|
|
||||||
|
This helper uses a tiered approach on Windows:
|
||||||
|
|
||||||
|
1. **ctypes ShellExecuteW** — calls the Win32 API directly so we can
|
||||||
|
pass ``SW_SHOWNORMAL`` explicitly. No intermediate ``cmd.exe``
|
||||||
|
involved, so no console window should appear.
|
||||||
|
2. **rundll32 url.dll** — a well-known Windows technique to open URLs
|
||||||
|
via a pure-GUI helper DLL. ``rundll32.exe`` is a GUI subsystem
|
||||||
|
binary so it never allocates a console. Used as a fallback when
|
||||||
|
ctypes is unavailable or ShellExecuteW returns an error code.
|
||||||
|
3. **webbrowser.open** — stdlib last-resort fallback.
|
||||||
|
|
||||||
|
``os.startfile`` is intentionally omitted: it is another thin wrapper
|
||||||
|
around ``ShellExecuteExW`` and therefore redundant with attempt 1.
|
||||||
|
|
||||||
|
On non-Windows platforms this simply delegates to ``webbrowser.open``.
|
||||||
|
"""
|
||||||
|
if sys.platform != "win32":
|
||||||
|
try:
|
||||||
|
return webbrowser.open(url)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# --- Windows path ---
|
||||||
|
|
||||||
|
# Attempt 1: ctypes ShellExecuteW — most direct, avoids any console.
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
SW_SHOWNORMAL = 1
|
||||||
|
result = ctypes.windll.shell32.ShellExecuteW(
|
||||||
|
None, # hwnd
|
||||||
|
"open", # operation
|
||||||
|
url, # file/URL
|
||||||
|
None, # parameters
|
||||||
|
None, # directory
|
||||||
|
SW_SHOWNORMAL,
|
||||||
|
)
|
||||||
|
# ShellExecuteW returns > 32 on success.
|
||||||
|
if result > 32:
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("_open_browser: ShellExecuteW failed: %s", exc)
|
||||||
|
|
||||||
|
# Attempt 2: rundll32 url.dll — a GUI-subsystem binary, no console.
|
||||||
|
try:
|
||||||
|
startupinfo = build_windows_hidden_startupinfo()
|
||||||
|
popen_kwargs: dict[str, Any] = {
|
||||||
|
"stdout": subprocess.DEVNULL,
|
||||||
|
"stderr": subprocess.DEVNULL,
|
||||||
|
}
|
||||||
|
if startupinfo is not None:
|
||||||
|
popen_kwargs["startupinfo"] = startupinfo
|
||||||
|
|
||||||
|
subprocess.Popen(["rundll32", "url.dll,FileProtocolHandler", url], **popen_kwargs) # noqa: S607
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("_open_browser: rundll32 fallback failed: %s", exc)
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Attempt 3: stdlib fallback.
|
||||||
|
try:
|
||||||
|
return webbrowser.open(url)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class OAuthLoginError(Exception):
|
class OAuthLoginError(Exception):
|
||||||
"""Error during OAuth login flow."""
|
"""Error during OAuth login flow."""
|
||||||
|
|
||||||
|
|
@ -496,36 +663,44 @@ def build_coordinator_url(host: str, port: int | None) -> str:
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def oauth_callback_server(state: str) -> Generator[OAuthCallbackServer, None, None]:
|
def oauth_callback_server(
|
||||||
|
state: str, port: int = LOCAL_CALLBACK_PORT
|
||||||
|
) -> Generator[OAuthCallbackServer, None, None]:
|
||||||
"""
|
"""
|
||||||
Context manager for the OAuth callback server.
|
Context manager for the OAuth callback server.
|
||||||
|
|
||||||
Ensures the server is properly shut down even if an error occurs.
|
Ensures the server is properly shut down even if an error occurs.
|
||||||
Waits for the callback to be received before exiting.
|
The caller is responsible for waiting on the callback result.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
with oauth_callback_server(state) as server:
|
with oauth_callback_server(state) as server:
|
||||||
# server is running and waiting for callback
|
# server is running and waiting for callback
|
||||||
...
|
...
|
||||||
# After the with block, the server has received the callback
|
# After the with block, the server has been shut down
|
||||||
"""
|
"""
|
||||||
server = OAuthCallbackServer(state)
|
server = OAuthCallbackServer(state, port=port)
|
||||||
server_thread = threading.Thread(target=server.run_server)
|
# daemon=True ensures the thread is killed automatically when the main
|
||||||
|
# process exits (e.g. user presses Ctrl-C during login). Without it the
|
||||||
|
# blocking serve_forever() call would keep the process alive until the
|
||||||
|
# HTTP timeout expires, even after the CLI has printed an error.
|
||||||
|
server_thread = threading.Thread(target=server.run_server, daemon=True)
|
||||||
server_thread.start()
|
server_thread.start()
|
||||||
|
# Give slower CI runners enough time to schedule the server thread and bind.
|
||||||
|
if not server.wait_until_ready(timeout=5.0):
|
||||||
|
server.shutdown_server()
|
||||||
|
server_thread.join(timeout=2)
|
||||||
|
raise RuntimeError("Failed to start local callback server.")
|
||||||
try:
|
try:
|
||||||
yield server
|
yield server
|
||||||
# Wait for the callback to be received (server shuts itself down after handling)
|
|
||||||
server_thread.join()
|
|
||||||
finally:
|
finally:
|
||||||
# Clean up if interrupted or if something went wrong
|
server.shutdown_server()
|
||||||
if server_thread.is_alive():
|
server_thread.join(timeout=2)
|
||||||
server.shutdown_server()
|
|
||||||
server_thread.join(timeout=2)
|
|
||||||
|
|
||||||
|
|
||||||
def perform_oauth_login(
|
def perform_oauth_login(
|
||||||
coordinator_url: str,
|
coordinator_url: str,
|
||||||
on_status: Callable[[str], None] | None = None,
|
on_status: Callable[[str], None] | None = None,
|
||||||
|
callback_timeout_seconds: int | None = None,
|
||||||
) -> OAuthLoginResult:
|
) -> OAuthLoginResult:
|
||||||
"""
|
"""
|
||||||
Perform the complete OAuth login flow.
|
Perform the complete OAuth login flow.
|
||||||
|
|
@ -540,6 +715,7 @@ def perform_oauth_login(
|
||||||
Args:
|
Args:
|
||||||
coordinator_url: Base URL of the Coordinator
|
coordinator_url: Base URL of the Coordinator
|
||||||
on_status: Optional callback for status messages (e.g., console.print)
|
on_status: Optional callback for status messages (e.g., console.print)
|
||||||
|
callback_timeout_seconds: Optional timeout for the local callback server
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
OAuthLoginResult with tokens and user info
|
OAuthLoginResult with tokens and user info
|
||||||
|
|
@ -562,21 +738,39 @@ def perform_oauth_login(
|
||||||
oauth_client = create_oauth_client(cli_config)
|
oauth_client = create_oauth_client(cli_config)
|
||||||
state = str(uuid.uuid4())
|
state = str(uuid.uuid4())
|
||||||
|
|
||||||
|
timeout_seconds = (
|
||||||
|
callback_timeout_seconds
|
||||||
|
if callback_timeout_seconds is not None
|
||||||
|
else DEFAULT_OAUTH_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
|
if timeout_seconds <= 0:
|
||||||
|
timeout_seconds = DEFAULT_OAUTH_TIMEOUT_SECONDS
|
||||||
|
|
||||||
# Step 3: Start local callback server and run browser auth
|
# Step 3: Start local callback server and run browser auth
|
||||||
with oauth_callback_server(state) as server:
|
try:
|
||||||
redirect_uri = server.get_redirect_uri()
|
with oauth_callback_server(state) as server:
|
||||||
|
redirect_uri = server.get_redirect_uri()
|
||||||
|
|
||||||
# Step 4: Generate authorization URL and open browser
|
# Step 4: Generate authorization URL and open browser
|
||||||
auth_url, code_verifier = generate_authorization_url(
|
auth_url, code_verifier = generate_authorization_url(
|
||||||
oauth_client, cli_config, redirect_uri, state
|
oauth_client, cli_config, redirect_uri, state
|
||||||
)
|
)
|
||||||
|
|
||||||
status("Opening a browser to log you in...")
|
status("Opening a browser to log you in...")
|
||||||
if not webbrowser.open(auth_url):
|
browser_opened = _open_browser(auth_url)
|
||||||
status(f"Copy this URL into your browser:\n{auth_url}")
|
|
||||||
|
|
||||||
# Step 5: Wait for callback (server thread handles this via serve_forever)
|
if not browser_opened:
|
||||||
# The thread will exit when the callback handler calls server.shutdown()
|
status(
|
||||||
|
"Could not open a browser automatically.\n"
|
||||||
|
f"Open this link to log in:\n{auth_url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
status(f"Waiting for login to complete (timeout: {timeout_seconds}s)...")
|
||||||
|
server.wait_for_result(timeout_seconds)
|
||||||
|
except OAuthLoginError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise OAuthLoginError(str(e)) from e
|
||||||
|
|
||||||
# Check for errors from callback
|
# Check for errors from callback
|
||||||
if "error" in server.result:
|
if "error" in server.result:
|
||||||
|
|
@ -614,7 +808,7 @@ def _credentials_file_contains_legacy() -> bool:
|
||||||
Detect legacy (API key) credentials in the credentials file.
|
Detect legacy (API key) credentials in the credentials file.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with open(CREDENTIALS_FILE_PATH) as f:
|
with open(CREDENTIALS_FILE_PATH, encoding="utf-8") as f:
|
||||||
data = yaml.safe_load(f) or {}
|
data = yaml.safe_load(f) or {}
|
||||||
cloud = data.get("cloud", {})
|
cloud = data.get("cloud", {})
|
||||||
return isinstance(cloud, dict) and "api" in cloud
|
return isinstance(cloud, dict) and "api" in cloud
|
||||||
|
|
@ -636,7 +830,7 @@ def check_existing_login(suppress_message: bool = False) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(CREDENTIALS_FILE_PATH) as f:
|
with open(CREDENTIALS_FILE_PATH, encoding="utf-8") as f:
|
||||||
config_data: dict[str, Any] = yaml.safe_load(f)
|
config_data: dict[str, Any] = yaml.safe_load(f)
|
||||||
|
|
||||||
cloud_config = config_data.get("cloud", {}) if isinstance(config_data, dict) else {}
|
cloud_config = config_data.get("cloud", {}) if isinstance(config_data, dict) else {}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,9 @@ Configuration utilities for the Arcade CLI.
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from rich.console import Console
|
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
console = Console()
|
from arcade_cli.console import console
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""Connect command for configuring MCP clients."""
|
"""Connect command for configuring MCP clients."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
|
|
@ -10,9 +11,10 @@ from pathlib import Path
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
from dotenv import dotenv_values
|
from dotenv import dotenv_values
|
||||||
from rich.console import Console
|
|
||||||
|
|
||||||
console = Console()
|
from arcade_cli.console import console
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def is_wsl() -> bool:
|
def is_wsl() -> bool:
|
||||||
|
|
@ -23,7 +25,7 @@ def is_wsl() -> bool:
|
||||||
|
|
||||||
# Check /proc/version for WSL indicators
|
# Check /proc/version for WSL indicators
|
||||||
try:
|
try:
|
||||||
with open("/proc/version") as f:
|
with open("/proc/version", encoding="utf-8") as f:
|
||||||
version_info = f.read().lower()
|
version_info = f.read().lower()
|
||||||
return "microsoft" in version_info or "wsl" in version_info
|
return "microsoft" in version_info or "wsl" in version_info
|
||||||
except (FileNotFoundError, PermissionError):
|
except (FileNotFoundError, PermissionError):
|
||||||
|
|
@ -53,6 +55,67 @@ def get_windows_username() -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_windows_appdata() -> Path:
|
||||||
|
"""Resolve the Windows roaming AppData directory via ``platformdirs``.
|
||||||
|
|
||||||
|
``platformdirs`` is the de-facto standard Python library for resolving
|
||||||
|
OS-specific user directories. On Windows it reads the ``APPDATA``
|
||||||
|
environment variable (and the Windows registry as a fallback), so a
|
||||||
|
single call covers every real-world scenario.
|
||||||
|
"""
|
||||||
|
from platformdirs import user_data_dir
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = user_data_dir(appname=None, appauthor=False, roaming=True)
|
||||||
|
except TypeError:
|
||||||
|
# Older platformdirs versions require positional args only.
|
||||||
|
# Signature: user_data_dir(appname, appauthor, version, roaming)
|
||||||
|
logger.debug("platformdirs raised TypeError; retrying with positional args")
|
||||||
|
result = user_data_dir(None, False, None, True)
|
||||||
|
|
||||||
|
return Path(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_paths(paths: list[Path]) -> list[Path]:
|
||||||
|
"""Return paths in order, removing duplicates (case-insensitive on Windows)."""
|
||||||
|
deduped: list[Path] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for path in paths:
|
||||||
|
key = os.path.normcase(str(path))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
deduped.append(path)
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
|
def _get_windows_cursor_config_paths() -> list[Path]:
|
||||||
|
"""Return known Windows Cursor config locations (primary first)."""
|
||||||
|
return _dedupe_paths([
|
||||||
|
_resolve_windows_appdata() / "Cursor" / "mcp.json",
|
||||||
|
Path.home() / ".cursor" / "mcp.json",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _format_path_for_display(path: Path, platform_system: str | None = None) -> str:
|
||||||
|
path_str = str(path)
|
||||||
|
if " " in path_str:
|
||||||
|
system = platform_system or platform.system()
|
||||||
|
if system == "Windows":
|
||||||
|
return f'"{path_str}"'
|
||||||
|
return path_str.replace(" ", "\\ ")
|
||||||
|
return path_str
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_overwrite(config: dict, section: str, server_name: str, config_path: Path) -> None:
|
||||||
|
if section in config and server_name in config[section]:
|
||||||
|
config_display = _format_path_for_display(config_path)
|
||||||
|
console.print(
|
||||||
|
f"[yellow]Warning: MCP server '{server_name}' already exists in {config_display}. "
|
||||||
|
"This will overwrite the existing entry. Use --name to keep both.[/yellow]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_claude_config_path() -> Path:
|
def get_claude_config_path() -> Path:
|
||||||
"""Get the Claude Desktop configuration file path."""
|
"""Get the Claude Desktop configuration file path."""
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
|
|
@ -65,7 +128,7 @@ def get_claude_config_path() -> Path:
|
||||||
/ "claude_desktop_config.json"
|
/ "claude_desktop_config.json"
|
||||||
)
|
)
|
||||||
elif system == "Windows":
|
elif system == "Windows":
|
||||||
return Path(os.environ["APPDATA"]) / "Claude" / "claude_desktop_config.json"
|
return _resolve_windows_appdata() / "Claude" / "claude_desktop_config.json"
|
||||||
else: # Linux
|
else: # Linux
|
||||||
# Check if we're in WSL - if so, use Windows path
|
# Check if we're in WSL - if so, use Windows path
|
||||||
if is_wsl():
|
if is_wsl():
|
||||||
|
|
@ -90,7 +153,11 @@ def get_cursor_config_path() -> Path:
|
||||||
if system == "Darwin": # macOS
|
if system == "Darwin": # macOS
|
||||||
return Path.home() / ".cursor" / "mcp.json"
|
return Path.home() / ".cursor" / "mcp.json"
|
||||||
elif system == "Windows":
|
elif system == "Windows":
|
||||||
return Path(os.environ["APPDATA"]) / "Cursor" / "mcp.json"
|
candidates = _get_windows_cursor_config_paths()
|
||||||
|
for path in candidates:
|
||||||
|
if path.exists():
|
||||||
|
return path
|
||||||
|
return candidates[0]
|
||||||
else: # Linux
|
else: # Linux
|
||||||
# Check if we're in WSL - if so, use Windows path
|
# Check if we're in WSL - if so, use Windows path
|
||||||
if is_wsl():
|
if is_wsl():
|
||||||
|
|
@ -114,7 +181,7 @@ def get_vscode_config_path() -> Path:
|
||||||
if system == "Darwin": # macOS
|
if system == "Darwin": # macOS
|
||||||
return Path.home() / "Library" / "Application Support" / "Code" / "User" / "mcp.json"
|
return Path.home() / "Library" / "Application Support" / "Code" / "User" / "mcp.json"
|
||||||
elif system == "Windows":
|
elif system == "Windows":
|
||||||
return Path(os.environ["APPDATA"]) / "Code" / "User" / "mcp.json"
|
return _resolve_windows_appdata() / "Code" / "User" / "mcp.json"
|
||||||
else: # Linux
|
else: # Linux
|
||||||
# Check if we're in WSL - if so, use Windows path
|
# Check if we're in WSL - if so, use Windows path
|
||||||
if is_wsl():
|
if is_wsl():
|
||||||
|
|
@ -179,9 +246,12 @@ def get_stdio_config(entrypoint_file: str, server_name: str) -> dict:
|
||||||
"""Get the appropriate stdio configuration based on whether uv is installed."""
|
"""Get the appropriate stdio configuration based on whether uv is installed."""
|
||||||
server_file = Path.cwd() / entrypoint_file
|
server_file = Path.cwd() / entrypoint_file
|
||||||
|
|
||||||
if is_uv_installed():
|
uv_executable = shutil.which("uv")
|
||||||
|
if uv_executable:
|
||||||
return {
|
return {
|
||||||
"command": "uv",
|
# Use the absolute uv path so GUI clients can launch reliably even
|
||||||
|
# when they were started with a different PATH than the shell.
|
||||||
|
"command": uv_executable,
|
||||||
"args": [
|
"args": [
|
||||||
"run",
|
"run",
|
||||||
"--directory",
|
"--directory",
|
||||||
|
|
@ -218,27 +288,32 @@ def configure_claude_local(
|
||||||
# Load existing config or create new one
|
# Load existing config or create new one
|
||||||
config = {}
|
config = {}
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
with open(config_path) as f:
|
with open(config_path, encoding="utf-8") as f:
|
||||||
config = json.load(f)
|
config = json.load(f)
|
||||||
|
|
||||||
# Add or update MCP servers configuration
|
# Add or update MCP servers configuration
|
||||||
if "mcpServers" not in config:
|
if "mcpServers" not in config:
|
||||||
config["mcpServers"] = {}
|
config["mcpServers"] = {}
|
||||||
|
|
||||||
|
_warn_overwrite(config, "mcpServers", server_name, config_path)
|
||||||
|
|
||||||
# Claude Desktop uses stdio transport
|
# Claude Desktop uses stdio transport
|
||||||
config["mcpServers"][server_name] = get_stdio_config(entrypoint_file, server_name)
|
config["mcpServers"][server_name] = get_stdio_config(entrypoint_file, server_name)
|
||||||
|
|
||||||
# Write updated config
|
# Write updated config
|
||||||
with open(config_path, "w") as f:
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(config, f, indent=2)
|
json.dump(config, f, indent=2)
|
||||||
|
|
||||||
console.print(
|
console.print(
|
||||||
f"✅ Configured Claude Desktop by adding local MCP server '{server_name}' to the configuration",
|
f"✅ Configured Claude Desktop by adding local MCP server '{server_name}' to the configuration",
|
||||||
style="green",
|
style="green",
|
||||||
)
|
)
|
||||||
config_file_path = config_path.as_posix().replace(" ", "\\ ")
|
config_file_path = _format_path_for_display(config_path)
|
||||||
console.print(f" MCP client config file: {config_file_path}", style="dim")
|
console.print(f" MCP client config file: {config_file_path}", style="dim")
|
||||||
console.print(f" Server file: {Path.cwd() / entrypoint_file}", style="dim")
|
console.print(
|
||||||
|
f" Server file: {_format_path_for_display(Path.cwd() / entrypoint_file)}",
|
||||||
|
style="dim",
|
||||||
|
)
|
||||||
if is_uv_installed():
|
if is_uv_installed():
|
||||||
console.print(" Using uv to run server", style="dim")
|
console.print(" Using uv to run server", style="dim")
|
||||||
else:
|
else:
|
||||||
|
|
@ -271,40 +346,64 @@ def configure_cursor_local(
|
||||||
"url": f"http://localhost:{port}/mcp",
|
"url": f"http://localhost:{port}/mcp",
|
||||||
}
|
}
|
||||||
|
|
||||||
config_path = config_path or get_cursor_config_path()
|
if config_path is not None:
|
||||||
|
target_paths = [config_path]
|
||||||
|
elif platform.system() == "Windows":
|
||||||
|
primary_path = get_cursor_config_path()
|
||||||
|
target_paths = _dedupe_paths([primary_path, *_get_windows_cursor_config_paths()])
|
||||||
|
else:
|
||||||
|
target_paths = [get_cursor_config_path()]
|
||||||
|
|
||||||
# Handle both absolute and relative config paths
|
# Handle both absolute and relative config paths.
|
||||||
if config_path and not config_path.is_absolute():
|
resolved_target_paths: list[Path] = []
|
||||||
config_path = Path.cwd() / config_path
|
for path in target_paths:
|
||||||
|
resolved_target_paths.append(path if path.is_absolute() else Path.cwd() / path)
|
||||||
|
|
||||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
server_config = (
|
||||||
|
|
||||||
# Load existing config or create new one
|
|
||||||
config = {}
|
|
||||||
if config_path.exists():
|
|
||||||
with open(config_path) as f:
|
|
||||||
config = json.load(f)
|
|
||||||
|
|
||||||
# Add or update MCP servers configuration
|
|
||||||
if "mcpServers" not in config:
|
|
||||||
config["mcpServers"] = {}
|
|
||||||
|
|
||||||
config["mcpServers"][server_name] = (
|
|
||||||
get_stdio_config(entrypoint_file, server_name)
|
get_stdio_config(entrypoint_file, server_name)
|
||||||
if transport == "stdio"
|
if transport == "stdio"
|
||||||
else http_config(server_name, port)
|
else http_config(server_name, port)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Write updated config
|
for idx, config_path in enumerate(resolved_target_paths):
|
||||||
with open(config_path, "w") as f:
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
json.dump(config, f, indent=2)
|
|
||||||
|
# Load existing config or create new one
|
||||||
|
config = {}
|
||||||
|
if config_path.exists():
|
||||||
|
with open(config_path, encoding="utf-8") as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
# Add or update MCP servers configuration
|
||||||
|
if "mcpServers" not in config:
|
||||||
|
config["mcpServers"] = {}
|
||||||
|
|
||||||
|
if idx == 0:
|
||||||
|
_warn_overwrite(config, "mcpServers", server_name, config_path)
|
||||||
|
|
||||||
|
config["mcpServers"][server_name] = server_config
|
||||||
|
|
||||||
|
# Write updated config
|
||||||
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(config, f, indent=2)
|
||||||
|
|
||||||
|
primary_config_path = resolved_target_paths[0]
|
||||||
|
|
||||||
console.print(
|
console.print(
|
||||||
f"✅ Configured Cursor by adding local MCP server '{server_name}' to the configuration",
|
f"✅ Configured Cursor by adding local MCP server '{server_name}' to the configuration",
|
||||||
style="green",
|
style="green",
|
||||||
)
|
)
|
||||||
config_file_path = config_path.as_posix().replace(" ", "\\ ")
|
config_file_path = _format_path_for_display(primary_config_path)
|
||||||
console.print(f" MCP client config file: {config_file_path}", style="dim")
|
console.print(f" MCP client config file: {config_file_path}", style="dim")
|
||||||
|
compatibility_paths = resolved_target_paths[1:]
|
||||||
|
if compatibility_paths:
|
||||||
|
compatibility_display = ", ".join(
|
||||||
|
_format_path_for_display(path) for path in compatibility_paths
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
f" Also updated compatibility config file(s): {compatibility_display}",
|
||||||
|
style="dim",
|
||||||
|
)
|
||||||
if transport == "http":
|
if transport == "http":
|
||||||
console.print(f" MCP Server URL: http://localhost:{port}/mcp", style="dim")
|
console.print(f" MCP Server URL: http://localhost:{port}/mcp", style="dim")
|
||||||
elif transport == "stdio":
|
elif transport == "stdio":
|
||||||
|
|
@ -347,12 +446,12 @@ def configure_vscode_local(
|
||||||
# Load existing config or create new one
|
# Load existing config or create new one
|
||||||
config = {}
|
config = {}
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
with open(config_path) as f:
|
with open(config_path, encoding="utf-8") as f:
|
||||||
try:
|
try:
|
||||||
config = json.load(f)
|
config = json.load(f)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"\n\tFailed to load MCP configuration file at {config_path.as_posix()} "
|
f"\n\tFailed to load MCP configuration file at {_format_path_for_display(config_path)} "
|
||||||
f"\n\tThe file contains invalid JSON: {e}. "
|
f"\n\tThe file contains invalid JSON: {e}. "
|
||||||
"\n\tPlease check the file format or delete it to create a new configuration."
|
"\n\tPlease check the file format or delete it to create a new configuration."
|
||||||
)
|
)
|
||||||
|
|
@ -361,6 +460,8 @@ def configure_vscode_local(
|
||||||
if "servers" not in config:
|
if "servers" not in config:
|
||||||
config["servers"] = {}
|
config["servers"] = {}
|
||||||
|
|
||||||
|
_warn_overwrite(config, "servers", server_name, config_path)
|
||||||
|
|
||||||
config["servers"][server_name] = (
|
config["servers"][server_name] = (
|
||||||
get_stdio_config(entrypoint_file, server_name)
|
get_stdio_config(entrypoint_file, server_name)
|
||||||
if transport == "stdio"
|
if transport == "stdio"
|
||||||
|
|
@ -368,14 +469,14 @@ def configure_vscode_local(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Write updated config
|
# Write updated config
|
||||||
with open(config_path, "w") as f:
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(config, f, indent=2)
|
json.dump(config, f, indent=2)
|
||||||
|
|
||||||
console.print(
|
console.print(
|
||||||
f"✅ Configured VS Code by adding local MCP server '{server_name}' to the configuration",
|
f"✅ Configured VS Code by adding local MCP server '{server_name}' to the configuration",
|
||||||
style="green",
|
style="green",
|
||||||
)
|
)
|
||||||
config_file_path = config_path.as_posix().replace(" ", "\\ ")
|
config_file_path = _format_path_for_display(config_path)
|
||||||
console.print(f" MCP client config file: {config_file_path}", style="dim")
|
console.print(f" MCP client config file: {config_file_path}", style="dim")
|
||||||
if transport == "http":
|
if transport == "http":
|
||||||
console.print(f" MCP Server URL: http://localhost:{port}/mcp", style="dim")
|
console.print(f" MCP Server URL: http://localhost:{port}/mcp", style="dim")
|
||||||
|
|
|
||||||
43
libs/arcade-cli/arcade_cli/console.py
Normal file
43
libs/arcade-cli/arcade_cli/console.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""Shared console setup for Arcade CLI output."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
|
||||||
|
def _needs_utf8(encoding: str | None) -> bool:
|
||||||
|
if not encoding:
|
||||||
|
return True
|
||||||
|
return encoding.lower() not in {"utf-8", "utf8"}
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_windows_utf8() -> None:
|
||||||
|
"""Ensure Windows console encoding won't raise UnicodeEncodeError."""
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return
|
||||||
|
|
||||||
|
needs_utf8 = _needs_utf8(getattr(sys.stdout, "encoding", None)) or _needs_utf8(
|
||||||
|
getattr(sys.stderr, "encoding", None)
|
||||||
|
)
|
||||||
|
if not needs_utf8:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
if hasattr(sys.stderr, "reconfigure"):
|
||||||
|
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
except Exception: # noqa: S110
|
||||||
|
# Fall back to environment hint for child processes.
|
||||||
|
pass
|
||||||
|
|
||||||
|
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
_configure_windows_utf8()
|
||||||
|
|
||||||
|
# Shared console used across CLI modules.
|
||||||
|
console = Console()
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
@ -11,10 +12,14 @@ from pathlib import Path
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from arcade_core.subprocess_utils import (
|
||||||
|
get_windows_no_window_creationflags,
|
||||||
|
graceful_terminate_process,
|
||||||
|
)
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from rich.columns import Columns
|
from rich.columns import Columns
|
||||||
from rich.console import Console, Group
|
from rich.console import Group
|
||||||
from rich.live import Live
|
from rich.live import Live
|
||||||
from rich.prompt import Confirm
|
from rich.prompt import Confirm
|
||||||
from rich.spinner import Spinner
|
from rich.spinner import Spinner
|
||||||
|
|
@ -22,6 +27,7 @@ from rich.text import Text
|
||||||
from typing_extensions import Literal
|
from typing_extensions import Literal
|
||||||
|
|
||||||
from arcade_cli.configure import find_python_interpreter
|
from arcade_cli.configure import find_python_interpreter
|
||||||
|
from arcade_cli.console import console
|
||||||
from arcade_cli.secret import load_env_file
|
from arcade_cli.secret import load_env_file
|
||||||
from arcade_cli.utils import (
|
from arcade_cli.utils import (
|
||||||
compute_base_url,
|
compute_base_url,
|
||||||
|
|
@ -30,7 +36,8 @@ from arcade_cli.utils import (
|
||||||
validate_and_get_config,
|
validate_and_get_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
console = Console()
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Models
|
# Models
|
||||||
|
|
||||||
|
|
@ -362,6 +369,37 @@ def create_package_archive(package_dir: Path) -> str:
|
||||||
return package_bytes_b64
|
return package_bytes_b64
|
||||||
|
|
||||||
|
|
||||||
|
def _graceful_terminate(process: subprocess.Popen) -> None:
|
||||||
|
"""Terminate a subprocess using shared graceful shutdown semantics."""
|
||||||
|
graceful_terminate_process(process)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_server_process_stdio(debug: bool) -> tuple[int | None, int | None]:
|
||||||
|
"""Choose stdout/stderr targets for the temporary validation server process.
|
||||||
|
|
||||||
|
``arcade deploy`` starts a short-lived child server to validate the
|
||||||
|
entrypoint before uploading. The child's stdout/stderr must be handled
|
||||||
|
carefully:
|
||||||
|
|
||||||
|
* **Normal mode** (``debug=False``): the CLI doesn't display child output,
|
||||||
|
so both streams are sent to ``subprocess.DEVNULL``. This prevents a
|
||||||
|
chatty child from filling the OS pipe buffer and blocking — which
|
||||||
|
manifests as intermittent health-check timeouts, especially on Windows
|
||||||
|
where the default pipe buffer is only 4 KiB.
|
||||||
|
* **Debug mode** (``debug=True``): both streams are inherited from the
|
||||||
|
parent process (``None``), so the user sees live startup logs in their
|
||||||
|
terminal for troubleshooting.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``(stdout_target, stderr_target)`` — each is either
|
||||||
|
``subprocess.DEVNULL`` or ``None`` (inherit).
|
||||||
|
"""
|
||||||
|
if debug:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
return subprocess.DEVNULL, subprocess.DEVNULL
|
||||||
|
|
||||||
|
|
||||||
def start_server_process(entrypoint: str, debug: bool = False) -> tuple[subprocess.Popen, int]:
|
def start_server_process(entrypoint: str, debug: bool = False) -> tuple[subprocess.Popen, int]:
|
||||||
"""
|
"""
|
||||||
Start the MCP server process on a random port.
|
Start the MCP server process on a random port.
|
||||||
|
|
@ -395,25 +433,39 @@ def start_server_process(entrypoint: str, debug: bool = False) -> tuple[subproce
|
||||||
project_python = find_python_interpreter()
|
project_python = find_python_interpreter()
|
||||||
cmd = [str(project_python), entrypoint]
|
cmd = [str(project_python), entrypoint]
|
||||||
|
|
||||||
|
creationflags = get_windows_no_window_creationflags(new_process_group=True)
|
||||||
|
|
||||||
|
stdout_target, stderr_target = _resolve_server_process_stdio(debug)
|
||||||
|
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=stdout_target,
|
||||||
stderr=subprocess.PIPE,
|
stderr=stderr_target,
|
||||||
text=True,
|
text=True,
|
||||||
env=env,
|
env=env,
|
||||||
|
creationflags=creationflags,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for immediate failure on start up
|
# Check for immediate failure on startup.
|
||||||
|
# stdout/stderr are either DEVNULL (non-debug) or inherited (debug), so
|
||||||
|
# communicate() returns (None, None) in both cases — there is nothing to
|
||||||
|
# capture. Surface a context-appropriate hint to the user instead.
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
if process.poll() is not None:
|
if process.poll() is not None:
|
||||||
_, stderr = process.communicate()
|
if debug:
|
||||||
error_msg = stderr.strip() if stderr else "Unknown error"
|
raise ValueError(
|
||||||
raise ValueError(f"Server process exited immediately: {error_msg}")
|
"Server process exited immediately. " "Check the server output above for details."
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
"Server process exited immediately. " "Re-run with --debug to see server startup logs."
|
||||||
|
)
|
||||||
|
|
||||||
return process, port
|
return process, port
|
||||||
|
|
||||||
|
|
||||||
def wait_for_health(base_url: str, process: subprocess.Popen, timeout: int = 30) -> None:
|
def wait_for_health(
|
||||||
|
base_url: str, process: subprocess.Popen, timeout: int = 30, debug: bool = False
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Wait for the server to become healthy.
|
Wait for the server to become healthy.
|
||||||
|
|
||||||
|
|
@ -421,6 +473,7 @@ def wait_for_health(base_url: str, process: subprocess.Popen, timeout: int = 30)
|
||||||
base_url: Base URL of the server
|
base_url: Base URL of the server
|
||||||
process: The server process
|
process: The server process
|
||||||
timeout: Maximum time to wait in seconds
|
timeout: Maximum time to wait in seconds
|
||||||
|
debug: Whether debug mode is active (affects the hint in the error message)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If the server doesn't become healthy within timeout
|
ValueError: If the server doesn't become healthy within timeout
|
||||||
|
|
@ -442,13 +495,24 @@ def wait_for_health(base_url: str, process: subprocess.Popen, timeout: int = 30)
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
if not is_healthy:
|
if not is_healthy:
|
||||||
process.terminate()
|
_graceful_terminate(process)
|
||||||
try:
|
try:
|
||||||
_, stderr = process.communicate(timeout=2)
|
process.communicate(timeout=2)
|
||||||
error_msg = stderr.strip() if stderr else "Server failed to become healthy"
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
process.kill()
|
process.kill()
|
||||||
error_msg = f"Server failed to become healthy within {timeout} seconds"
|
|
||||||
|
# stdout/stderr are DEVNULL (non-debug) or inherited (debug), so
|
||||||
|
# communicate() never captures output — build a context-appropriate message.
|
||||||
|
if debug:
|
||||||
|
error_msg = (
|
||||||
|
f"Server failed to become healthy within {timeout} seconds. "
|
||||||
|
"Check the server output above for details."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
error_msg = (
|
||||||
|
f"Server failed to become healthy within {timeout} seconds. "
|
||||||
|
"Re-run with --debug to see server startup logs."
|
||||||
|
)
|
||||||
raise ValueError(error_msg)
|
raise ValueError(error_msg)
|
||||||
|
|
||||||
console.print("✓ Server is healthy", style="green")
|
console.print("✓ Server is healthy", style="green")
|
||||||
|
|
@ -575,7 +639,7 @@ def verify_server_and_get_metadata(
|
||||||
base_url = f"http://127.0.0.1:{port}"
|
base_url = f"http://127.0.0.1:{port}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
wait_for_health(base_url, process)
|
wait_for_health(base_url, process, debug=debug)
|
||||||
|
|
||||||
server_name, server_version = get_server_info(base_url)
|
server_name, server_version = get_server_info(base_url)
|
||||||
|
|
||||||
|
|
@ -586,7 +650,7 @@ def verify_server_and_get_metadata(
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Always stop the server
|
# Always stop the server
|
||||||
process.terminate()
|
_graceful_terminate(process)
|
||||||
try:
|
try:
|
||||||
process.wait(timeout=5)
|
process.wait(timeout=5)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,10 @@ from rich.panel import Panel
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
|
from arcade_cli.console import console
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from arcade_evals.eval import EvaluationResult
|
from arcade_evals.eval import EvaluationResult
|
||||||
console = Console()
|
|
||||||
|
|
||||||
|
|
||||||
def display_tools_table(tools: list[ToolDefinition]) -> None:
|
def display_tools_table(tools: list[ToolDefinition]) -> None:
|
||||||
|
|
|
||||||
|
|
@ -2,24 +2,26 @@ import asyncio
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import webbrowser
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import typer
|
import typer
|
||||||
from arcade_core.constants import CREDENTIALS_FILE_PATH, PROD_COORDINATOR_HOST, PROD_ENGINE_HOST
|
from arcade_core.constants import CREDENTIALS_FILE_PATH, PROD_COORDINATOR_HOST, PROD_ENGINE_HOST
|
||||||
|
from arcade_core.subprocess_utils import get_windows_no_window_creationflags
|
||||||
from arcadepy import Arcade
|
from arcadepy import Arcade
|
||||||
from rich.console import Console
|
|
||||||
|
|
||||||
from arcade_cli.authn import (
|
from arcade_cli.authn import (
|
||||||
|
DEFAULT_OAUTH_TIMEOUT_SECONDS,
|
||||||
OAuthLoginError,
|
OAuthLoginError,
|
||||||
_credentials_file_contains_legacy,
|
_credentials_file_contains_legacy,
|
||||||
|
_open_browser,
|
||||||
build_coordinator_url,
|
build_coordinator_url,
|
||||||
check_existing_login,
|
check_existing_login,
|
||||||
perform_oauth_login,
|
perform_oauth_login,
|
||||||
save_credentials_from_whoami,
|
save_credentials_from_whoami,
|
||||||
)
|
)
|
||||||
|
from arcade_cli.console import console
|
||||||
from arcade_cli.evals_runner import run_capture, run_evaluations
|
from arcade_cli.evals_runner import run_capture, run_evaluations
|
||||||
from arcade_cli.org import app as org_app
|
from arcade_cli.org import app as org_app
|
||||||
from arcade_cli.project import app as project_app
|
from arcade_cli.project import app as project_app
|
||||||
|
|
@ -73,9 +75,6 @@ cli.add_typer(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
console = Console()
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command(help="Log in to Arcade", rich_help_panel="User")
|
@cli.command(help="Log in to Arcade", rich_help_panel="User")
|
||||||
def login(
|
def login(
|
||||||
host: str = typer.Option(
|
host: str = typer.Option(
|
||||||
|
|
@ -90,6 +89,11 @@ def login(
|
||||||
"--port",
|
"--port",
|
||||||
help="The port of the Arcade Coordinator host (if running locally).",
|
help="The port of the Arcade Coordinator host (if running locally).",
|
||||||
),
|
),
|
||||||
|
timeout: int = typer.Option(
|
||||||
|
DEFAULT_OAUTH_TIMEOUT_SECONDS,
|
||||||
|
"--timeout",
|
||||||
|
help="Seconds to wait for the local login callback.",
|
||||||
|
),
|
||||||
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
|
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
|
|
@ -107,6 +111,7 @@ def login(
|
||||||
result = perform_oauth_login(
|
result = perform_oauth_login(
|
||||||
coordinator_url,
|
coordinator_url,
|
||||||
on_status=lambda msg: console.print(msg, style="dim"),
|
on_status=lambda msg: console.print(msg, style="dim"),
|
||||||
|
callback_timeout_seconds=timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save credentials
|
# Save credentials
|
||||||
|
|
@ -127,7 +132,7 @@ def login(
|
||||||
except OAuthLoginError as e:
|
except OAuthLoginError as e:
|
||||||
if debug:
|
if debug:
|
||||||
console.print(f"Debug: {e.__cause__}", style="dim")
|
console.print(f"Debug: {e.__cause__}", style="dim")
|
||||||
handle_cli_error(str(e), should_exit=False)
|
handle_cli_error(str(e), should_exit=True)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("\nLogin cancelled.", style="yellow")
|
console.print("\nLogin cancelled.", style="yellow")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -148,6 +153,13 @@ def logout(
|
||||||
console.print("You're now logged out.", style="bold")
|
console.print("You're now logged out.", style="bold")
|
||||||
else:
|
else:
|
||||||
console.print("You're not logged in.", style="bold red")
|
console.print("You're not logged in.", style="bold red")
|
||||||
|
except PermissionError:
|
||||||
|
# On Windows, the file may be locked by another process.
|
||||||
|
handle_cli_error(
|
||||||
|
"Could not remove credentials file — it may be in use by another process. "
|
||||||
|
"Close other Arcade instances and try again.",
|
||||||
|
should_exit=True,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
handle_cli_error("Logout failed", e, debug)
|
handle_cli_error("Logout failed", e, debug)
|
||||||
|
|
||||||
|
|
@ -163,8 +175,11 @@ def whoami(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = Config.load_from_file()
|
config = Config.load_from_file()
|
||||||
|
except FileNotFoundError:
|
||||||
|
console.print("Not logged in. Run 'arcade login' to authenticate.", style="bold red")
|
||||||
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
handle_cli_error("Failed to read credentials", e, debug)
|
handle_cli_error("Failed to read credentials", e, debug, should_exit=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Defensive - should not happen, because the main() callback prevents this:
|
# Defensive - should not happen, because the main() callback prevents this:
|
||||||
|
|
@ -311,8 +326,16 @@ def mcp(
|
||||||
if debug:
|
if debug:
|
||||||
console.print(f"[dim]Running: {' '.join(cmd)}[/dim]")
|
console.print(f"[dim]Running: {' '.join(cmd)}[/dim]")
|
||||||
|
|
||||||
# Execute the command and pass through all output
|
# Execute the command and pass through all output.
|
||||||
result = subprocess.run(cmd, check=False)
|
# On Windows, set CREATE_NO_WINDOW to prevent a phantom console
|
||||||
|
# window from appearing (e.g. when an MCP client spawns this
|
||||||
|
# command without an attached console). The child process still
|
||||||
|
# inherits stdin/stdout/stderr for stdio transport communication.
|
||||||
|
run_kwargs: dict[str, object] = {"check": False}
|
||||||
|
creation_flags = get_windows_no_window_creationflags()
|
||||||
|
if creation_flags:
|
||||||
|
run_kwargs["creationflags"] = creation_flags
|
||||||
|
result = subprocess.run(cmd, **run_kwargs)
|
||||||
|
|
||||||
# Exit with the same code as the subprocess
|
# Exit with the same code as the subprocess
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
|
|
@ -918,7 +941,7 @@ def dashboard(
|
||||||
|
|
||||||
# Open the dashboard in a browser
|
# Open the dashboard in a browser
|
||||||
console.print(f"Opening Arcade Dashboard at {dashboard_url}")
|
console.print(f"Opening Arcade Dashboard at {dashboard_url}")
|
||||||
if not webbrowser.open(dashboard_url):
|
if not _open_browser(dashboard_url):
|
||||||
console.print(
|
console.print(
|
||||||
f"If a browser doesn't open automatically, copy this URL and paste it into your browser: {dashboard_url}",
|
f"If a browser doesn't open automatically, copy this URL and paste it into your browser: {dashboard_url}",
|
||||||
style="dim",
|
style="dim",
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,10 @@ from typing import Optional
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
from rich.console import Console
|
|
||||||
|
|
||||||
|
from arcade_cli.console import console
|
||||||
from arcade_cli.templates import get_full_template_directory, get_minimal_template_directory
|
from arcade_cli.templates import get_full_template_directory, get_minimal_template_directory
|
||||||
|
|
||||||
console = Console()
|
|
||||||
|
|
||||||
# Retrieve the installed version of arcade-mcp
|
# Retrieve the installed version of arcade-mcp
|
||||||
try:
|
try:
|
||||||
ARCADE_MCP_MIN_VERSION = get_version("arcade-mcp")
|
ARCADE_MCP_MIN_VERSION = get_version("arcade-mcp")
|
||||||
|
|
@ -144,7 +142,11 @@ def remove_toolkit(toolkit_directory: Path, toolkit_name: str) -> None:
|
||||||
"""Teardown logic for when creating a new toolkit fails."""
|
"""Teardown logic for when creating a new toolkit fails."""
|
||||||
toolkit_path = toolkit_directory / toolkit_name
|
toolkit_path = toolkit_directory / toolkit_name
|
||||||
if toolkit_path.exists():
|
if toolkit_path.exists():
|
||||||
shutil.rmtree(toolkit_path)
|
try:
|
||||||
|
shutil.rmtree(toolkit_path)
|
||||||
|
except (PermissionError, OSError) as e:
|
||||||
|
# On Windows, files may still be locked by another process.
|
||||||
|
console.print(f"[yellow]Warning: Could not fully remove '{toolkit_path}': {e}[/yellow]")
|
||||||
|
|
||||||
|
|
||||||
def create_new_toolkit(output_directory: str, toolkit_name: str) -> None:
|
def create_new_toolkit(output_directory: str, toolkit_name: str) -> None:
|
||||||
|
|
@ -224,6 +226,15 @@ def create_new_toolkit(output_directory: str, toolkit_name: str) -> None:
|
||||||
console.print(
|
console.print(
|
||||||
f"[green]Toolkit '{toolkit_name}' created successfully at '{toolkit_directory}'.[/green]"
|
f"[green]Toolkit '{toolkit_name}' created successfully at '{toolkit_directory}'.[/green]"
|
||||||
)
|
)
|
||||||
|
console.print("\nNext steps:", style="bold")
|
||||||
|
console.print(f" cd {toolkit_directory / toolkit_name}")
|
||||||
|
console.print("")
|
||||||
|
console.print(" Run with stdio transport (for MCP clients):", style="dim")
|
||||||
|
console.print(" uv run server.py")
|
||||||
|
console.print("")
|
||||||
|
console.print(" Run with HTTP transport (for development/testing):", style="dim")
|
||||||
|
console.print(" uv run server.py --transport http --port 8000")
|
||||||
|
console.print("")
|
||||||
create_deployment(toolkit_directory, toolkit_name)
|
create_deployment(toolkit_directory, toolkit_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
remove_toolkit(toolkit_directory, toolkit_name)
|
remove_toolkit(toolkit_directory, toolkit_name)
|
||||||
|
|
@ -271,9 +282,20 @@ def create_new_toolkit_minimal(output_directory: str, toolkit_name: str) -> None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
create_package(env, template_directory, toolkit_directory, context, ignore_pattern)
|
create_package(env, template_directory, toolkit_directory, context, ignore_pattern)
|
||||||
|
console.print("")
|
||||||
console.print(
|
console.print(
|
||||||
f"[green]Server '{toolkit_name}' created successfully at '{toolkit_directory}'.[/green]"
|
f"[green]Server '{toolkit_name}' created successfully at '{toolkit_directory}'.[/green]"
|
||||||
)
|
)
|
||||||
|
server_dir = toolkit_directory / toolkit_name / "src" / toolkit_name
|
||||||
|
console.print("\nNext steps:", style="bold")
|
||||||
|
console.print(f" cd {server_dir}")
|
||||||
|
console.print("")
|
||||||
|
console.print(" Run with stdio transport (for MCP clients):", style="dim")
|
||||||
|
console.print(" uv run server.py")
|
||||||
|
console.print("")
|
||||||
|
console.print(" Run with HTTP transport (for development/testing):", style="dim")
|
||||||
|
console.print(" uv run server.py --transport http --port 8000")
|
||||||
|
console.print("")
|
||||||
except Exception:
|
except Exception:
|
||||||
remove_toolkit(toolkit_directory, toolkit_name)
|
remove_toolkit(toolkit_directory, toolkit_name)
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,18 @@
|
||||||
import typer
|
import typer
|
||||||
from arcade_core.constants import PROD_COORDINATOR_HOST
|
from arcade_core.constants import PROD_COORDINATOR_HOST
|
||||||
from rich.console import Console
|
|
||||||
|
|
||||||
from arcade_cli.authn import (
|
from arcade_cli.authn import (
|
||||||
fetch_organizations,
|
fetch_organizations,
|
||||||
fetch_projects,
|
fetch_projects,
|
||||||
select_default_project,
|
select_default_project,
|
||||||
)
|
)
|
||||||
|
from arcade_cli.console import console
|
||||||
from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup
|
from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup
|
||||||
from arcade_cli.utils import (
|
from arcade_cli.utils import (
|
||||||
compute_base_url,
|
compute_base_url,
|
||||||
handle_cli_error,
|
handle_cli_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
console = Console()
|
|
||||||
|
|
||||||
|
|
||||||
app = TrackedTyper(
|
app = TrackedTyper(
|
||||||
cls=TrackedTyperGroup,
|
cls=TrackedTyperGroup,
|
||||||
add_completion=False,
|
add_completion=False,
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,14 @@
|
||||||
import typer
|
import typer
|
||||||
from arcade_core.constants import PROD_COORDINATOR_HOST
|
from arcade_core.constants import PROD_COORDINATOR_HOST
|
||||||
from rich.console import Console
|
|
||||||
|
|
||||||
from arcade_cli.authn import fetch_projects
|
from arcade_cli.authn import fetch_projects
|
||||||
|
from arcade_cli.console import console
|
||||||
from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup
|
from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup
|
||||||
from arcade_cli.utils import (
|
from arcade_cli.utils import (
|
||||||
compute_base_url,
|
compute_base_url,
|
||||||
handle_cli_error,
|
handle_cli_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
console = Console()
|
|
||||||
|
|
||||||
|
|
||||||
app = TrackedTyper(
|
app = TrackedTyper(
|
||||||
cls=TrackedTyperGroup,
|
cls=TrackedTyperGroup,
|
||||||
add_completion=False,
|
add_completion=False,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import httpx
|
import httpx
|
||||||
import typer
|
import typer
|
||||||
from arcade_core.constants import PROD_ENGINE_HOST
|
from arcade_core.constants import PROD_ENGINE_HOST
|
||||||
from rich.console import Console
|
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
|
from arcade_cli.console import console
|
||||||
from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup
|
from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup
|
||||||
from arcade_cli.utils import (
|
from arcade_cli.utils import (
|
||||||
compute_base_url,
|
compute_base_url,
|
||||||
|
|
@ -11,9 +11,6 @@ from arcade_cli.utils import (
|
||||||
get_org_scoped_url,
|
get_org_scoped_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
console = Console()
|
|
||||||
|
|
||||||
|
|
||||||
app = TrackedTyper(
|
app = TrackedTyper(
|
||||||
cls=TrackedTyperGroup,
|
cls=TrackedTyperGroup,
|
||||||
add_completion=False,
|
add_completion=False,
|
||||||
|
|
@ -182,7 +179,7 @@ def print_secret_table(secrets: list[dict]) -> None:
|
||||||
def load_env_file(env_file_path: str) -> dict[str, str]:
|
def load_env_file(env_file_path: str) -> dict[str, str]:
|
||||||
"""Load tool secrets from a .env file."""
|
"""Load tool secrets from a .env file."""
|
||||||
secrets = {}
|
secrets = {}
|
||||||
with open(env_file_path) as file:
|
with open(env_file_path, encoding="utf-8") as file:
|
||||||
for line in file:
|
for line in file:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line.startswith("#") or not line:
|
if line.startswith("#") or not line:
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@ from arcade_core.constants import PROD_ENGINE_HOST
|
||||||
from arcadepy import NotFoundError
|
from arcadepy import NotFoundError
|
||||||
from arcadepy.types import WorkerHealthResponse, WorkerResponse
|
from arcadepy.types import WorkerHealthResponse, WorkerResponse
|
||||||
from dateutil import parser
|
from dateutil import parser
|
||||||
from rich.console import Console
|
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
|
from arcade_cli.console import console
|
||||||
from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup
|
from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup
|
||||||
from arcade_cli.utils import (
|
from arcade_cli.utils import (
|
||||||
compute_base_url,
|
compute_base_url,
|
||||||
|
|
@ -22,8 +22,6 @@ from arcade_cli.utils import (
|
||||||
handle_cli_error,
|
handle_cli_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
console = Console()
|
|
||||||
|
|
||||||
|
|
||||||
def _format_timestamp_to_local(timestamp_str: str) -> str:
|
def _format_timestamp_to_local(timestamp_str: str) -> str:
|
||||||
"""
|
"""
|
||||||
|
|
@ -325,7 +323,7 @@ def _display_deployment_logs(
|
||||||
logs = response.json()
|
logs = response.json()
|
||||||
for log in logs:
|
for log in logs:
|
||||||
formatted_timestamp = _format_timestamp_to_local(log["timestamp"])
|
formatted_timestamp = _format_timestamp_to_local(log["timestamp"])
|
||||||
print(f"[{formatted_timestamp}] {log['line']}")
|
console.print(f"[{formatted_timestamp}] {log['line']}", markup=False)
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
handle_cli_error(
|
handle_cli_error(
|
||||||
f"Failed to fetch logs: {e.response.status_code} {e.response.text}", debug=debug
|
f"Failed to fetch logs: {e.response.status_code} {e.response.text}", debug=debug
|
||||||
|
|
@ -359,11 +357,11 @@ async def _stream_deployment_logs(
|
||||||
timestamp_str = data.get("Timestamp", "")
|
timestamp_str = data.get("Timestamp", "")
|
||||||
log_line = data.get("Line", "")
|
log_line = data.get("Line", "")
|
||||||
formatted_timestamp = _format_timestamp_to_local(timestamp_str)
|
formatted_timestamp = _format_timestamp_to_local(timestamp_str)
|
||||||
print(f"[{formatted_timestamp}] {log_line}")
|
console.print(f"[{formatted_timestamp}] {log_line}", markup=False)
|
||||||
except (json.JSONDecodeError, KeyError, IndexError):
|
except (json.JSONDecodeError, KeyError, IndexError):
|
||||||
print(line)
|
console.print(line, markup=False)
|
||||||
else:
|
else:
|
||||||
print(line)
|
console.print(line, markup=False)
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
handle_cli_error(f"Failed to stream logs: {e.response.status_code}", debug=debug)
|
handle_cli_error(f"Failed to stream logs: {e.response.status_code}", debug=debug)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from importlib import metadata
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
from arcade_cli.console import console
|
||||||
from arcade_cli.usage.constants import (
|
from arcade_cli.usage.constants import (
|
||||||
EVENT_CLI_COMMAND_EXECUTED,
|
EVENT_CLI_COMMAND_EXECUTED,
|
||||||
EVENT_CLI_COMMAND_FAILED,
|
EVENT_CLI_COMMAND_FAILED,
|
||||||
|
|
@ -27,12 +28,9 @@ from arcade_core.usage.constants import (
|
||||||
PROP_RUNTIME_LANGUAGE,
|
PROP_RUNTIME_LANGUAGE,
|
||||||
PROP_RUNTIME_VERSION,
|
PROP_RUNTIME_VERSION,
|
||||||
)
|
)
|
||||||
from rich.console import Console
|
|
||||||
from typer.core import TyperCommand, TyperGroup
|
from typer.core import TyperCommand, TyperGroup
|
||||||
from typer.models import Context
|
from typer.models import Context
|
||||||
|
|
||||||
console = Console()
|
|
||||||
|
|
||||||
|
|
||||||
class CommandTracker:
|
class CommandTracker:
|
||||||
"""Tracks CLI command execution for usage analytics."""
|
"""Tracks CLI command execution for usage analytics."""
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,11 @@ from arcadepy import (
|
||||||
)
|
)
|
||||||
from arcadepy.types import AuthorizationResponse
|
from arcadepy.types import AuthorizationResponse
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from rich.console import Console
|
|
||||||
from rich.markup import escape
|
from rich.markup import escape
|
||||||
from typer.core import TyperGroup
|
from typer.core import TyperGroup
|
||||||
from typer.models import Context
|
from typer.models import Context
|
||||||
|
|
||||||
console = Console()
|
from arcade_cli.console import console
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Shared helpers for the CLI
|
# Shared helpers for the CLI
|
||||||
|
|
@ -1084,7 +1082,7 @@ def load_dotenv(path: str | Path, *, override: bool = False) -> dict[str, str]:
|
||||||
|
|
||||||
loaded: dict[str, str] = {}
|
loaded: dict[str, str] = {}
|
||||||
|
|
||||||
for raw in path.read_text().splitlines():
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||||
parsed = _parse_line(raw.strip())
|
parsed = _parse_line(raw.strip())
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -6,6 +8,54 @@ from typing import Any
|
||||||
import yaml
|
import yaml
|
||||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_windows_owner_acl(config_file_path: Path) -> None:
|
||||||
|
"""Restrict a file so only the current user can read/write it on Windows.
|
||||||
|
|
||||||
|
On POSIX systems ``chmod 600`` removes group/other access. On Windows,
|
||||||
|
``Path.chmod()`` only toggles the read-only flag and does **not** change
|
||||||
|
who can access the file. To get equivalent protection we use the built-in
|
||||||
|
``icacls`` command to manipulate NTFS Access Control Lists (ACLs):
|
||||||
|
|
||||||
|
1. ``/inheritance:r`` — remove all inherited Access Control Entries (ACEs).
|
||||||
|
By default every file inherits broad permissions from its parent folder
|
||||||
|
(e.g. ``Users:(RX)``). Stripping inheritance leaves the file with an
|
||||||
|
empty ACL, meaning *no one* can access it yet.
|
||||||
|
2. ``/grant:r USERNAME:(R,W)`` — add a single explicit ACE that grants
|
||||||
|
the current user Read and Write access. The ``:r`` flag replaces any
|
||||||
|
existing ACE for that user rather than merging.
|
||||||
|
|
||||||
|
Both flags are passed in a **single** ``icacls`` invocation so there is no
|
||||||
|
window where the file has an empty ACL (which would make it temporarily
|
||||||
|
inaccessible to everyone, including the owner).
|
||||||
|
|
||||||
|
The net effect is that only the logged-in Windows user can read or modify
|
||||||
|
the credentials file — the same security posture as ``chmod 600`` on Unix.
|
||||||
|
"""
|
||||||
|
username = os.environ.get("USERNAME")
|
||||||
|
if not username:
|
||||||
|
raise OSError("USERNAME is not set; cannot apply Windows ACL restrictions")
|
||||||
|
|
||||||
|
# Strip inherited permissions and grant only the current user R+W access in
|
||||||
|
# a single icacls call. Using two separate calls would leave the file with
|
||||||
|
# an empty ACL (nobody can access it) between the first and second call; if
|
||||||
|
# the second call were to fail the file would be permanently inaccessible.
|
||||||
|
subprocess.run(
|
||||||
|
[ # noqa: S607
|
||||||
|
"icacls",
|
||||||
|
str(config_file_path),
|
||||||
|
"/inheritance:r",
|
||||||
|
"/grant:r",
|
||||||
|
f"{username}:(R,W)",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BaseConfig(BaseModel):
|
class BaseConfig(BaseModel):
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
@ -182,7 +232,7 @@ class Config(BaseConfig):
|
||||||
"Please run 'arcade login' to create your configuration."
|
"Please run 'arcade login' to create your configuration."
|
||||||
)
|
)
|
||||||
|
|
||||||
config_data = yaml.safe_load(config_file_path.read_text())
|
config_data = yaml.safe_load(config_file_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
if config_data is None:
|
if config_data is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
|
@ -230,7 +280,22 @@ class Config(BaseConfig):
|
||||||
|
|
||||||
# Convert to dict, excluding None values for cleaner output
|
# Convert to dict, excluding None values for cleaner output
|
||||||
data = {"cloud": self.model_dump(exclude_none=True, mode="json")}
|
data = {"cloud": self.model_dump(exclude_none=True, mode="json")}
|
||||||
config_file_path.write_text(yaml.dump(data, default_flow_style=False))
|
config_file_path.write_text(yaml.dump(data, default_flow_style=False), encoding="utf-8")
|
||||||
|
|
||||||
# Set restrictive permissions (owner read/write only)
|
# Restrict the credentials file so only the current user can read it.
|
||||||
config_file_path.chmod(0o600)
|
# - Unix: chmod 600 (removes group/other access via file-mode bits).
|
||||||
|
# - Windows: icacls to strip inherited ACEs and grant only the current
|
||||||
|
# user R/W access (see _set_windows_owner_acl for details).
|
||||||
|
# Failure is non-fatal: the file is still written, but a warning is
|
||||||
|
# logged so the user knows the permissions could not be tightened.
|
||||||
|
try:
|
||||||
|
if os.name == "nt":
|
||||||
|
_set_windows_owner_acl(config_file_path)
|
||||||
|
else:
|
||||||
|
config_file_path.chmod(0o600)
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Unable to apply restrictive permissions to %s: %s",
|
||||||
|
config_file_path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ def load_ast_tree(filepath: str | Path) -> ast.AST:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with open(filepath) as file:
|
with open(filepath, encoding="utf-8") as file:
|
||||||
return ast.parse(file.read(), filename=filepath)
|
return ast.parse(file.read(), filename=filepath)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise FileNotFoundError(f"File {filepath} not found")
|
raise FileNotFoundError(f"File {filepath} not found")
|
||||||
|
|
|
||||||
68
libs/arcade-core/arcade_core/subprocess_utils.py
Normal file
68
libs/arcade-core/arcade_core/subprocess_utils.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Shared Windows subprocess helpers used across Arcade libraries."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def get_windows_no_window_creationflags(*, new_process_group: bool = False) -> int:
|
||||||
|
"""Return Windows creation flags to suppress phantom console windows.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
new_process_group: When true, include ``CREATE_NEW_PROCESS_GROUP`` to
|
||||||
|
allow graceful ``CTRL_BREAK_EVENT`` signaling.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A bitmask of subprocess creation flags on Windows, otherwise ``0``.
|
||||||
|
"""
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return 0
|
||||||
|
|
||||||
|
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
|
||||||
|
if new_process_group:
|
||||||
|
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
|
||||||
|
return flags
|
||||||
|
|
||||||
|
|
||||||
|
def build_windows_hidden_startupinfo() -> Any | None:
|
||||||
|
"""Create a Windows ``STARTUPINFO`` configured with ``SW_HIDE``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A configured ``STARTUPINFO`` instance on Windows when available,
|
||||||
|
otherwise ``None``.
|
||||||
|
"""
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return None
|
||||||
|
|
||||||
|
startupinfo_cls = getattr(subprocess, "STARTUPINFO", None)
|
||||||
|
if startupinfo_cls is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
startupinfo = startupinfo_cls()
|
||||||
|
startupinfo.dwFlags |= getattr(subprocess, "STARTF_USESHOWWINDOW", 0x00000001)
|
||||||
|
startupinfo.wShowWindow = 0
|
||||||
|
return startupinfo
|
||||||
|
|
||||||
|
|
||||||
|
def graceful_terminate_process(process: subprocess.Popen[Any]) -> None:
|
||||||
|
"""Terminate a process with Windows-friendly graceful fallback behavior.
|
||||||
|
|
||||||
|
On Windows, try ``CTRL_BREAK_EVENT`` first (when supported) so child
|
||||||
|
processes can exit cleanly. If signaling fails, fall back to
|
||||||
|
``process.terminate()``. Any ``OSError`` during termination is swallowed
|
||||||
|
because the process may already have exited.
|
||||||
|
"""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
try:
|
||||||
|
process.send_signal(signal.CTRL_BREAK_EVENT)
|
||||||
|
except (OSError, AttributeError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
process.terminate()
|
||||||
|
|
@ -72,7 +72,7 @@ class Toolkit(BaseModel):
|
||||||
raise ToolkitLoadError(f"pyproject.toml not found in {directory}")
|
raise ToolkitLoadError(f"pyproject.toml not found in {directory}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(pyproject_path) as f:
|
with open(pyproject_path, encoding="utf-8") as f:
|
||||||
pyproject_data = toml.load(f)
|
pyproject_data = toml.load(f)
|
||||||
|
|
||||||
project_data = pyproject_data.get("project", {})
|
project_data = pyproject_data.get("project", {})
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ class UsageIdentity:
|
||||||
|
|
||||||
if os.path.exists(self.usage_file_path):
|
if os.path.exists(self.usage_file_path):
|
||||||
try:
|
try:
|
||||||
with open(self.usage_file_path) as f:
|
with open(self.usage_file_path, encoding="utf-8") as f:
|
||||||
# Lock file for reading (shared lock)
|
# Lock file for reading (shared lock)
|
||||||
portalocker.lock(f, portalocker.LOCK_SH)
|
portalocker.lock(f, portalocker.LOCK_SH)
|
||||||
try:
|
try:
|
||||||
|
|
@ -77,7 +77,7 @@ class UsageIdentity:
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with os.fdopen(temp_fd, "w") as f:
|
with os.fdopen(temp_fd, "w", encoding="utf-8") as f:
|
||||||
# Lock file for writing (exclusive lock)
|
# Lock file for writing (exclusive lock)
|
||||||
portalocker.lock(f, portalocker.LOCK_EX)
|
portalocker.lock(f, portalocker.LOCK_EX)
|
||||||
try:
|
try:
|
||||||
|
|
@ -141,7 +141,7 @@ class UsageIdentity:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(CREDENTIALS_FILE_PATH) as f:
|
with open(CREDENTIALS_FILE_PATH, encoding="utf-8") as f:
|
||||||
config = yaml.safe_load(f) or {}
|
config = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
cloud_config = config.get("cloud", {}) if isinstance(config, dict) else {}
|
cloud_config = config.get("cloud", {}) if isinstance(config, dict) else {}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from arcade_core.subprocess_utils import (
|
||||||
|
build_windows_hidden_startupinfo,
|
||||||
|
get_windows_no_window_creationflags,
|
||||||
|
)
|
||||||
from arcade_core.usage.constants import (
|
from arcade_core.usage.constants import (
|
||||||
ARCADE_USAGE_EVENT_DATA,
|
ARCADE_USAGE_EVENT_DATA,
|
||||||
MAX_RETRIES_POSTHOG,
|
MAX_RETRIES_POSTHOG,
|
||||||
|
|
@ -71,23 +77,28 @@ class UsageService:
|
||||||
"is_anon": is_anon,
|
"is_anon": is_anon,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd = [sys.executable, "-m", "arcade_core.usage"]
|
cmd_executable = _resolve_background_python_executable()
|
||||||
|
|
||||||
|
cmd = [cmd_executable, "-m", "arcade_core.usage"]
|
||||||
|
|
||||||
# Pass data via environment variable (works on all platforms)
|
# Pass data via environment variable (works on all platforms)
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env[ARCADE_USAGE_EVENT_DATA] = event_data
|
env[ARCADE_USAGE_EVENT_DATA] = event_data
|
||||||
|
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
# Windows: Use DETACHED_PROCESS to fully detach from parent console
|
# Windows: use CREATE_NO_WINDOW + SW_HIDE so the tracking worker
|
||||||
DETACHED_PROCESS = 0x00000008
|
# never flashes a console window. CREATE_NEW_PROCESS_GROUP keeps
|
||||||
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
# it isolated from Ctrl+C signals sent to the parent group.
|
||||||
|
creationflags = get_windows_no_window_creationflags(new_process_group=True)
|
||||||
|
startupinfo = build_windows_hidden_startupinfo()
|
||||||
|
|
||||||
subprocess.Popen(
|
subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP,
|
creationflags=creationflags,
|
||||||
|
startupinfo=startupinfo,
|
||||||
close_fds=True,
|
close_fds=True,
|
||||||
env=env,
|
env=env,
|
||||||
)
|
)
|
||||||
|
|
@ -102,3 +113,33 @@ class UsageService:
|
||||||
close_fds=True,
|
close_fds=True,
|
||||||
env=env,
|
env=env,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_background_python_executable() -> str:
|
||||||
|
"""Resolve the best interpreter for detached usage tracking."""
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return sys.executable
|
||||||
|
|
||||||
|
# Prefer a windowless interpreter on Windows to avoid flashing a console
|
||||||
|
# for short-lived tracking subprocesses.
|
||||||
|
candidates: list[Path] = []
|
||||||
|
candidates.append(Path(sys.executable).with_name("pythonw.exe"))
|
||||||
|
|
||||||
|
base_prefix = getattr(sys, "base_prefix", "")
|
||||||
|
if base_prefix:
|
||||||
|
candidates.append(Path(base_prefix) / "pythonw.exe")
|
||||||
|
|
||||||
|
which_pythonw = shutil.which("pythonw")
|
||||||
|
if which_pythonw:
|
||||||
|
candidates.append(Path(which_pythonw))
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
for candidate in candidates:
|
||||||
|
key = str(candidate).lower()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
if candidate.exists():
|
||||||
|
return str(candidate)
|
||||||
|
|
||||||
|
return sys.executable
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "arcade-core"
|
name = "arcade-core"
|
||||||
version = "4.4.0"
|
version = "4.4.2"
|
||||||
description = "Arcade Core - Core library for Arcade platform"
|
description = "Arcade Core - Core library for Arcade platform"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ class CaptureResult:
|
||||||
|
|
||||||
def write_to_file(self, file_path: str, include_context: bool = False, indent: int = 2) -> None:
|
def write_to_file(self, file_path: str, include_context: bool = False, indent: int = 2) -> None:
|
||||||
"""Write capture results to a JSON file."""
|
"""Write capture results to a JSON file."""
|
||||||
with open(file_path, "w") as f:
|
with open(file_path, "w", encoding="utf-8") as f:
|
||||||
f.write(self.to_json(include_context, indent))
|
f.write(self.to_json(include_context, indent))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,8 @@ __all__ = [
|
||||||
"LinkedIn",
|
"LinkedIn",
|
||||||
"Microsoft",
|
"Microsoft",
|
||||||
"Notion",
|
"Notion",
|
||||||
"PagerDuty",
|
|
||||||
"OAuth2",
|
"OAuth2",
|
||||||
|
"PagerDuty",
|
||||||
"Reddit",
|
"Reddit",
|
||||||
"Slack",
|
"Slack",
|
||||||
"Spotify",
|
"Spotify",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ from typing import Any, Callable, Literal, ParamSpec, TypeVar, cast
|
||||||
|
|
||||||
from arcade_core.catalog import MaterializedTool, ToolCatalog, ToolDefinitionError
|
from arcade_core.catalog import MaterializedTool, ToolCatalog, ToolDefinitionError
|
||||||
from arcade_core.metadata import ToolMetadata
|
from arcade_core.metadata import ToolMetadata
|
||||||
|
from arcade_core.subprocess_utils import (
|
||||||
|
get_windows_no_window_creationflags,
|
||||||
|
graceful_terminate_process,
|
||||||
|
)
|
||||||
from arcade_tdk.auth import ToolAuthorization
|
from arcade_tdk.auth import ToolAuthorization
|
||||||
from arcade_tdk.error_adapters import ErrorAdapter
|
from arcade_tdk.error_adapters import ErrorAdapter
|
||||||
from arcade_tdk.tool import tool as tool_decorator
|
from arcade_tdk.tool import tool as tool_decorator
|
||||||
|
|
@ -370,15 +374,27 @@ class MCPApp:
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["ARCADE_MCP_CHILD_PROCESS"] = "1"
|
env["ARCADE_MCP_CHILD_PROCESS"] = "1"
|
||||||
|
|
||||||
|
creationflags = get_windows_no_window_creationflags(new_process_group=True)
|
||||||
|
|
||||||
return subprocess.Popen(
|
return subprocess.Popen(
|
||||||
[sys.executable, *sys.argv],
|
[sys.executable, *sys.argv],
|
||||||
env=env,
|
env=env,
|
||||||
|
creationflags=creationflags,
|
||||||
)
|
)
|
||||||
|
|
||||||
def shutdown_server_process(process: subprocess.Popen, reason: str = "reload") -> None:
|
def shutdown_server_process(process: subprocess.Popen, reason: str = "reload") -> None:
|
||||||
"""Shutdown server process gracefully with fallback to force kill."""
|
"""Shutdown server process gracefully with fallback to force kill.
|
||||||
|
|
||||||
|
On Windows, ``process.terminate()`` calls ``TerminateProcess`` which
|
||||||
|
kills the child immediately — there is no graceful shutdown. To
|
||||||
|
allow the child to clean up we first try sending ``CTRL_BREAK_EVENT``
|
||||||
|
(requires ``CREATE_NEW_PROCESS_GROUP``), which Python's default
|
||||||
|
``SIGINT`` handler will catch as ``KeyboardInterrupt``. If that
|
||||||
|
doesn't work we fall back to ``terminate()`` / ``kill()``.
|
||||||
|
"""
|
||||||
logger.info(f"Shutting down server for {reason}...")
|
logger.info(f"Shutting down server for {reason}...")
|
||||||
process.terminate()
|
|
||||||
|
graceful_terminate_process(process)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
process.wait(timeout=5)
|
process.wait(timeout=5)
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ class StdioTransport:
|
||||||
self._shutdown_event = asyncio.Event()
|
self._shutdown_event = asyncio.Event()
|
||||||
self._running = False
|
self._running = False
|
||||||
self._sessions: dict[str, ServerSession] = {}
|
self._sessions: dict[str, ServerSession] = {}
|
||||||
|
self._stop_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the transport."""
|
"""Start the transport."""
|
||||||
|
|
@ -107,12 +108,21 @@ class StdioTransport:
|
||||||
try:
|
try:
|
||||||
loop.add_signal_handler(sig, lambda: asyncio.create_task(self.stop()))
|
loop.add_signal_handler(sig, lambda: asyncio.create_task(self.stop()))
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
# Windows doesn't support POSIX signals
|
if sys.platform != "win32":
|
||||||
if sys.platform == "win32":
|
|
||||||
logger.warning("Signal handling not fully supported on Windows")
|
|
||||||
else:
|
|
||||||
logger.warning(f"Failed to set up signal handler for {sig}")
|
logger.warning(f"Failed to set up signal handler for {sig}")
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# On Windows, asyncio signal handlers don't work but the stdlib
|
||||||
|
# signal.signal(SIGINT) *does* receive Ctrl+C. Register a
|
||||||
|
# fallback so that a Ctrl+C schedules a clean stop on the loop.
|
||||||
|
def _schedule_stop() -> None:
|
||||||
|
self._stop_task = loop.create_task(self.stop())
|
||||||
|
|
||||||
|
def _win_ctrl_c(signum: int, frame: object) -> None:
|
||||||
|
loop.call_soon_threadsafe(_schedule_stop)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, _win_ctrl_c)
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Stop the transport."""
|
"""Stop the transport."""
|
||||||
if not self._running:
|
if not self._running:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "arcade-mcp-server"
|
name = "arcade-mcp-server"
|
||||||
version = "1.17.0"
|
version = "1.17.2"
|
||||||
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
|
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{ name = "Arcade.dev" }]
|
authors = [{ name = "Arcade.dev" }]
|
||||||
|
|
@ -21,7 +21,7 @@ classifiers = [
|
||||||
]
|
]
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arcade-core>=4.4.0,<5.0.0",
|
"arcade-core>=4.4.2,<5.0.0",
|
||||||
"arcade-serve>=3.2.0,<4.0.0",
|
"arcade-serve>=3.2.0,<4.0.0",
|
||||||
"arcade-tdk>=3.6.0,<4.0.0",
|
"arcade-tdk>=3.6.0,<4.0.0",
|
||||||
"arcadepy>=1.5.0",
|
"arcadepy>=1.5.0",
|
||||||
|
|
|
||||||
|
|
@ -31,16 +31,16 @@ __all__ = [
|
||||||
"ClickUp",
|
"ClickUp",
|
||||||
"Discord",
|
"Discord",
|
||||||
"Dropbox",
|
"Dropbox",
|
||||||
"GitHub",
|
|
||||||
"Figma",
|
"Figma",
|
||||||
|
"GitHub",
|
||||||
"Google",
|
"Google",
|
||||||
"Hubspot",
|
"Hubspot",
|
||||||
"Linear",
|
"Linear",
|
||||||
"LinkedIn",
|
"LinkedIn",
|
||||||
"Microsoft",
|
"Microsoft",
|
||||||
"Notion",
|
"Notion",
|
||||||
"PagerDuty",
|
|
||||||
"OAuth2",
|
"OAuth2",
|
||||||
|
"PagerDuty",
|
||||||
"Reddit",
|
"Reddit",
|
||||||
"Slack",
|
"Slack",
|
||||||
"Spotify",
|
"Spotify",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from arcade_core.errors import (
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"ContextRequiredToolError",
|
||||||
"ErrorKind",
|
"ErrorKind",
|
||||||
"FatalToolError",
|
"FatalToolError",
|
||||||
"RetryableToolError",
|
"RetryableToolError",
|
||||||
|
|
@ -18,7 +19,6 @@ __all__ = [
|
||||||
"ToolRuntimeError",
|
"ToolRuntimeError",
|
||||||
"UpstreamError",
|
"UpstreamError",
|
||||||
"UpstreamRateLimitError",
|
"UpstreamRateLimitError",
|
||||||
"ContextRequiredToolError",
|
|
||||||
"WeightError",
|
"WeightError",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,11 @@ including initialize, ping, list tools, and tool execution with all key features
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
import queue
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -24,6 +27,43 @@ def get_entrypoint_path() -> str:
|
||||||
return str(Path(__file__).parent / "server" / "src" / "server" / "entrypoint.py")
|
return str(Path(__file__).parent / "server" / "src" / "server" / "entrypoint.py")
|
||||||
|
|
||||||
|
|
||||||
|
HTTP_STARTUP_TIMEOUT_SECONDS = 30 if sys.platform == "win32" else 10
|
||||||
|
|
||||||
|
|
||||||
|
def _find_open_tcp_port() -> int:
|
||||||
|
"""Reserve a free loopback TCP port and return it."""
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
return int(sock.getsockname()[1])
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_process(process: subprocess.Popen, timeout: float = 5.0) -> None:
|
||||||
|
"""Terminate subprocesses reliably, including uv child trees on Windows."""
|
||||||
|
if process.poll() is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
process.wait(timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
return
|
||||||
|
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
|
||||||
|
|
||||||
def start_mcp_server(
|
def start_mcp_server(
|
||||||
transport: str, port: int | None = None
|
transport: str, port: int | None = None
|
||||||
) -> tuple[subprocess.Popen, int | None]:
|
) -> tuple[subprocess.Popen, int | None]:
|
||||||
|
|
@ -56,7 +96,7 @@ def start_mcp_server(
|
||||||
|
|
||||||
elif transport == "http":
|
elif transport == "http":
|
||||||
if port is None:
|
if port is None:
|
||||||
port = random.randint(8000, 9000) # noqa: S311
|
port = _find_open_tcp_port()
|
||||||
|
|
||||||
env = {
|
env = {
|
||||||
**os.environ,
|
**os.environ,
|
||||||
|
|
@ -70,8 +110,8 @@ def start_mcp_server(
|
||||||
cmd = ["uv", "run", entrypoint_path, "http"]
|
cmd = ["uv", "run", entrypoint_path, "http"]
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.DEVNULL,
|
||||||
text=True,
|
text=True,
|
||||||
env=env,
|
env=env,
|
||||||
cwd=str(package_path),
|
cwd=str(package_path),
|
||||||
|
|
@ -104,7 +144,7 @@ def start_mcp_server_direct_python(
|
||||||
pytest.skip("Server venv not found - run 'uv sync' in integration/server first")
|
pytest.skip("Server venv not found - run 'uv sync' in integration/server first")
|
||||||
|
|
||||||
if port is None:
|
if port is None:
|
||||||
port = random.randint(8000, 9000) # noqa: S311
|
port = _find_open_tcp_port()
|
||||||
|
|
||||||
env = {
|
env = {
|
||||||
**os.environ,
|
**os.environ,
|
||||||
|
|
@ -118,8 +158,8 @@ def start_mcp_server_direct_python(
|
||||||
cmd = [str(venv_python), entrypoint_path, transport]
|
cmd = [str(venv_python), entrypoint_path, transport]
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.DEVNULL,
|
||||||
text=True,
|
text=True,
|
||||||
env=env,
|
env=env,
|
||||||
cwd=str(package_path),
|
cwd=str(package_path),
|
||||||
|
|
@ -127,21 +167,32 @@ def start_mcp_server_direct_python(
|
||||||
return process, port if transport == "http" else None
|
return process, port if transport == "http" else None
|
||||||
|
|
||||||
|
|
||||||
def wait_for_http_server_ready(port: int, timeout: int = 30) -> None:
|
def wait_for_http_server_ready(
|
||||||
|
port: int,
|
||||||
|
timeout: int = HTTP_STARTUP_TIMEOUT_SECONDS,
|
||||||
|
process: subprocess.Popen | None = None,
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Wait for HTTP server to become healthy.
|
Wait for HTTP server to become healthy.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
port: Server port
|
port: Server port
|
||||||
timeout: Maximum time to wait in seconds
|
timeout: Maximum time to wait in seconds
|
||||||
|
process: Optional subprocess handle for early-exit detection
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TimeoutError: If server doesn't become healthy within timeout
|
TimeoutError: If server doesn't become healthy within timeout
|
||||||
|
RuntimeError: If process exits before becoming healthy
|
||||||
"""
|
"""
|
||||||
health_url = f"http://127.0.0.1:{port}/worker/health"
|
health_url = f"http://127.0.0.1:{port}/worker/health"
|
||||||
start_time = time.time()
|
deadline = time.monotonic() + timeout
|
||||||
|
|
||||||
while time.time() - start_time < timeout:
|
while time.monotonic() < deadline:
|
||||||
|
if process is not None and process.poll() is not None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Server process exited with code {process.returncode} "
|
||||||
|
f"before becoming healthy on port {port}"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
response = httpx.get(health_url, timeout=2.0)
|
response = httpx.get(health_url, timeout=2.0)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
|
|
@ -251,6 +302,41 @@ class StdioClient:
|
||||||
def __init__(self, process: subprocess.Popen):
|
def __init__(self, process: subprocess.Popen):
|
||||||
self.process = process
|
self.process = process
|
||||||
self._next_id = 1
|
self._next_id = 1
|
||||||
|
self._stdout_messages: queue.Queue[dict[str, Any]] = queue.Queue()
|
||||||
|
self._stdout_reader_error: Exception | None = None
|
||||||
|
self._stderr_lines: list[str] = []
|
||||||
|
self._stdout_reader = threading.Thread(target=self._stdout_reader_loop, daemon=True)
|
||||||
|
self._stderr_reader = threading.Thread(target=self._stderr_reader_loop, daemon=True)
|
||||||
|
self._stdout_reader.start()
|
||||||
|
self._stderr_reader.start()
|
||||||
|
|
||||||
|
def _stdout_reader_loop(self) -> None:
|
||||||
|
"""Continuously drain stdout so timeout checks are not blocked by readline()."""
|
||||||
|
if self.process.stdout is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
for line in self.process.stdout:
|
||||||
|
message = parse_jsonrpc_message(line)
|
||||||
|
if message:
|
||||||
|
self._stdout_messages.put(message)
|
||||||
|
except Exception as exc:
|
||||||
|
self._stdout_reader_error = exc
|
||||||
|
|
||||||
|
def _stderr_reader_loop(self) -> None:
|
||||||
|
"""Drain stderr to avoid Windows pipe backpressure deadlocks."""
|
||||||
|
if self.process.stderr is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
for line in self.process.stderr:
|
||||||
|
self._stderr_lines.append(line.rstrip())
|
||||||
|
# Keep only the most recent lines for debugging.
|
||||||
|
if len(self._stderr_lines) > 25:
|
||||||
|
self._stderr_lines = self._stderr_lines[-25:]
|
||||||
|
except Exception:
|
||||||
|
# Best-effort diagnostics reader; failures here should not hide test results.
|
||||||
|
pass
|
||||||
|
|
||||||
def send_request(self, method: str, params: dict | None = None) -> int:
|
def send_request(self, method: str, params: dict | None = None) -> int:
|
||||||
"""Send a request and return the request ID."""
|
"""Send a request and return the request ID."""
|
||||||
|
|
@ -290,18 +376,28 @@ class StdioClient:
|
||||||
|
|
||||||
def read_response(self, timeout: float = 10.0) -> dict:
|
def read_response(self, timeout: float = 10.0) -> dict:
|
||||||
"""Read a response from the server."""
|
"""Read a response from the server."""
|
||||||
start_time = time.time()
|
deadline = time.monotonic() + timeout
|
||||||
|
|
||||||
while time.time() - start_time < timeout:
|
while time.monotonic() < deadline:
|
||||||
if self.process.stdout:
|
if self._stdout_reader_error is not None:
|
||||||
line = self.process.stdout.readline()
|
raise RuntimeError("Failed while reading stdio response") from self._stdout_reader_error
|
||||||
if line:
|
|
||||||
message = parse_jsonrpc_message(line)
|
|
||||||
if message:
|
|
||||||
return message
|
|
||||||
time.sleep(0.01)
|
|
||||||
|
|
||||||
raise TimeoutError("Timeout waiting for response")
|
try:
|
||||||
|
return self._stdout_messages.get(timeout=0.1)
|
||||||
|
except queue.Empty:
|
||||||
|
if self.process.poll() is not None and self._stdout_messages.empty():
|
||||||
|
stderr_tail = "\n".join(self._stderr_lines[-5:])
|
||||||
|
details = (
|
||||||
|
f"\nLast stderr lines:\n{stderr_tail}" if stderr_tail else "\nNo stderr captured."
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"MCP server exited with code {self.process.returncode} while waiting for response."
|
||||||
|
f"{details}"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
stderr_tail = "\n".join(self._stderr_lines[-5:])
|
||||||
|
details = f"\nLast stderr lines:\n{stderr_tail}" if stderr_tail else "\nNo stderr captured."
|
||||||
|
raise TimeoutError(f"Timeout waiting for response after {timeout:.1f}s.{details}")
|
||||||
|
|
||||||
def handle_bidirectional_request(self, message: dict) -> None:
|
def handle_bidirectional_request(self, message: dict) -> None:
|
||||||
"""Handle a server-initiated request by sending appropriate mock response."""
|
"""Handle a server-initiated request by sending appropriate mock response."""
|
||||||
|
|
@ -526,12 +622,7 @@ async def test_stdio_e2e():
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Clean shutdown
|
# Clean shutdown
|
||||||
process.terminate()
|
_cleanup_process(process)
|
||||||
try:
|
|
||||||
process.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
process.kill()
|
|
||||||
process.wait()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -543,7 +634,11 @@ async def test_http_e2e():
|
||||||
base_url = f"http://127.0.0.1:{port}"
|
base_url = f"http://127.0.0.1:{port}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
wait_for_http_server_ready(port, timeout=10)
|
wait_for_http_server_ready(
|
||||||
|
port,
|
||||||
|
timeout=HTTP_STARTUP_TIMEOUT_SECONDS,
|
||||||
|
process=process,
|
||||||
|
)
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|
@ -668,12 +763,7 @@ async def test_http_e2e():
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
process.terminate()
|
_cleanup_process(process)
|
||||||
try:
|
|
||||||
process.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
process.kill()
|
|
||||||
process.wait()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -685,7 +775,11 @@ async def test_http_mcp_concurrent_tool_execution():
|
||||||
base_url = f"http://127.0.0.1:{port}"
|
base_url = f"http://127.0.0.1:{port}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
wait_for_http_server_ready(port, timeout=10)
|
wait_for_http_server_ready(
|
||||||
|
port,
|
||||||
|
timeout=HTTP_STARTUP_TIMEOUT_SECONDS,
|
||||||
|
process=process,
|
||||||
|
)
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|
@ -768,12 +862,7 @@ async def test_http_mcp_concurrent_tool_execution():
|
||||||
assert total_time < max_expected_time
|
assert total_time < max_expected_time
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
process.terminate()
|
_cleanup_process(process)
|
||||||
try:
|
|
||||||
process.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
process.kill()
|
|
||||||
process.wait()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -785,7 +874,11 @@ async def test_http_worker_concurrent_tool_execution():
|
||||||
base_url = f"http://127.0.0.1:{port}"
|
base_url = f"http://127.0.0.1:{port}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
wait_for_http_server_ready(port, timeout=10)
|
wait_for_http_server_ready(
|
||||||
|
port,
|
||||||
|
timeout=HTTP_STARTUP_TIMEOUT_SECONDS,
|
||||||
|
process=process,
|
||||||
|
)
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|
@ -844,12 +937,7 @@ async def test_http_worker_concurrent_tool_execution():
|
||||||
assert total_time < max_expected_time
|
assert total_time < max_expected_time
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
process.terminate()
|
_cleanup_process(process)
|
||||||
try:
|
|
||||||
process.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
process.kill()
|
|
||||||
process.wait()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -861,7 +949,11 @@ async def test_http_mixed_route_concurrent_execution():
|
||||||
base_url = f"http://127.0.0.1:{port}"
|
base_url = f"http://127.0.0.1:{port}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
wait_for_http_server_ready(port, timeout=10)
|
wait_for_http_server_ready(
|
||||||
|
port,
|
||||||
|
timeout=HTTP_STARTUP_TIMEOUT_SECONDS,
|
||||||
|
process=process,
|
||||||
|
)
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|
@ -952,12 +1044,7 @@ async def test_http_mixed_route_concurrent_execution():
|
||||||
assert total_time < max_expected_time
|
assert total_time < max_expected_time
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
process.terminate()
|
_cleanup_process(process)
|
||||||
try:
|
|
||||||
process.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
process.kill()
|
|
||||||
process.wait()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -967,7 +1054,11 @@ async def test_http_direct_python_invocation():
|
||||||
assert port is not None
|
assert port is not None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
wait_for_http_server_ready(port, timeout=10)
|
wait_for_http_server_ready(
|
||||||
|
port,
|
||||||
|
timeout=HTTP_STARTUP_TIMEOUT_SECONDS,
|
||||||
|
process=process,
|
||||||
|
)
|
||||||
|
|
||||||
# Verify server is healthy and tools are discoverable
|
# Verify server is healthy and tools are discoverable
|
||||||
headers = {
|
headers = {
|
||||||
|
|
@ -1009,9 +1100,4 @@ async def test_http_direct_python_invocation():
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
process.terminate()
|
_cleanup_process(process)
|
||||||
try:
|
|
||||||
process.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
process.kill()
|
|
||||||
process.wait()
|
|
||||||
|
|
|
||||||
|
|
@ -415,8 +415,13 @@ class TestMCPApp:
|
||||||
# Verify both processes were created
|
# Verify both processes were created
|
||||||
assert mock_popen.call_count == 2
|
assert mock_popen.call_count == 2
|
||||||
|
|
||||||
# Verify first process was terminated
|
# Verify first process was shut down.
|
||||||
mock_process1.terminate.assert_called_once()
|
# On Windows, shutdown uses send_signal(CTRL_BREAK_EVENT) instead
|
||||||
|
# of terminate() for graceful shutdown.
|
||||||
|
if sys.platform == "win32":
|
||||||
|
mock_process1.send_signal.assert_called_once()
|
||||||
|
else:
|
||||||
|
mock_process1.terminate.assert_called_once()
|
||||||
mock_process1.wait.assert_called()
|
mock_process1.wait.assert_called()
|
||||||
|
|
||||||
def test_run_with_reload_graceful_shutdown(self, mcp_app: MCPApp):
|
def test_run_with_reload_graceful_shutdown(self, mcp_app: MCPApp):
|
||||||
|
|
@ -433,8 +438,13 @@ class TestMCPApp:
|
||||||
|
|
||||||
mcp_app._run_with_reload("127.0.0.1", 8000)
|
mcp_app._run_with_reload("127.0.0.1", 8000)
|
||||||
|
|
||||||
# Verify graceful shutdown
|
# Verify graceful shutdown.
|
||||||
mock_process.terminate.assert_called()
|
# On Windows, send_signal(CTRL_BREAK_EVENT) is used instead of
|
||||||
|
# terminate() to allow graceful child cleanup.
|
||||||
|
if sys.platform == "win32":
|
||||||
|
mock_process.send_signal.assert_called()
|
||||||
|
else:
|
||||||
|
mock_process.terminate.assert_called()
|
||||||
mock_process.wait.assert_called()
|
mock_process.wait.assert_called()
|
||||||
mock_process.kill.assert_not_called()
|
mock_process.kill.assert_not_called()
|
||||||
|
|
||||||
|
|
@ -453,8 +463,12 @@ class TestMCPApp:
|
||||||
|
|
||||||
mcp_app._run_with_reload("127.0.0.1", 8000)
|
mcp_app._run_with_reload("127.0.0.1", 8000)
|
||||||
|
|
||||||
# Verify terminate -> wait -> kill -> wait sequence
|
# Verify shutdown -> wait -> kill -> wait sequence.
|
||||||
mock_process.terminate.assert_called()
|
# On Windows, send_signal is used instead of terminate.
|
||||||
|
if sys.platform == "win32":
|
||||||
|
mock_process.send_signal.assert_called()
|
||||||
|
else:
|
||||||
|
mock_process.terminate.assert_called()
|
||||||
assert mock_process.wait.call_count == 2
|
assert mock_process.wait.call_count == 2
|
||||||
mock_process.kill.assert_called_once()
|
mock_process.kill.assert_called_once()
|
||||||
|
|
||||||
|
|
@ -472,8 +486,11 @@ class TestMCPApp:
|
||||||
# Should not raise exception
|
# Should not raise exception
|
||||||
mcp_app._run_with_reload("127.0.0.1", 8000)
|
mcp_app._run_with_reload("127.0.0.1", 8000)
|
||||||
|
|
||||||
# Verify process was shut down
|
# Verify process was shut down.
|
||||||
mock_process.terminate.assert_called_once()
|
if sys.platform == "win32":
|
||||||
|
mock_process.send_signal.assert_called_once()
|
||||||
|
else:
|
||||||
|
mock_process.terminate.assert_called_once()
|
||||||
|
|
||||||
def test_run_routes_to_reload_method(self, mcp_app: MCPApp):
|
def test_run_routes_to_reload_method(self, mcp_app: MCPApp):
|
||||||
"""Test run() routes to _run_with_reload when reload=True."""
|
"""Test run() routes to _run_with_reload when reload=True."""
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import tarfile
|
import tarfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from arcade_cli.deploy import (
|
from arcade_cli.deploy import (
|
||||||
|
|
@ -58,10 +60,24 @@ version = "0.1.0"
|
||||||
description = "Test project"
|
description = "Test project"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
"""
|
"""
|
||||||
(project_dir / "pyproject.toml").write_text(pyproject_content)
|
(project_dir / "pyproject.toml").write_text(pyproject_content, encoding="utf-8")
|
||||||
return project_dir
|
return project_dir
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def reserved_unreachable_local_url():
|
||||||
|
"""Yield a localhost URL that is guaranteed not to have an HTTP listener.
|
||||||
|
|
||||||
|
Keeps a TCP socket bound (without listen()) so no other process can claim
|
||||||
|
the port during the test, avoiding flaky collisions with long-lived local
|
||||||
|
dev servers.
|
||||||
|
"""
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
port = sock.getsockname()[1]
|
||||||
|
yield f"http://127.0.0.1:{port}"
|
||||||
|
|
||||||
|
|
||||||
# Tests for create_package_archive
|
# Tests for create_package_archive
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -97,7 +113,7 @@ def test_create_package_archive_nonexistent_dir(tmp_path: Path) -> None:
|
||||||
def test_create_package_archive_file_not_dir(tmp_path: Path) -> None:
|
def test_create_package_archive_file_not_dir(tmp_path: Path) -> None:
|
||||||
"""Test that archiving a file instead of directory raises ValueError."""
|
"""Test that archiving a file instead of directory raises ValueError."""
|
||||||
test_file = tmp_path / "test_file.txt"
|
test_file = tmp_path / "test_file.txt"
|
||||||
test_file.write_text("test content")
|
test_file.write_text("test content", encoding="utf-8")
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Package path must be a directory"):
|
with pytest.raises(ValueError, match="Package path must be a directory"):
|
||||||
create_package_archive(test_file)
|
create_package_archive(test_file)
|
||||||
|
|
@ -109,18 +125,18 @@ def test_create_package_archive_excludes_files(tmp_path: Path) -> None:
|
||||||
test_dir.mkdir()
|
test_dir.mkdir()
|
||||||
|
|
||||||
# Create files that should be excluded
|
# Create files that should be excluded
|
||||||
(test_dir / ".hidden").write_text("hidden")
|
(test_dir / ".hidden").write_text("hidden", encoding="utf-8")
|
||||||
(test_dir / "__pycache__").mkdir()
|
(test_dir / "__pycache__").mkdir()
|
||||||
(test_dir / "__pycache__" / "cache.pyc").write_text("cache")
|
(test_dir / "__pycache__" / "cache.pyc").write_text("cache", encoding="utf-8")
|
||||||
(test_dir / "requirements.lock").write_text("lock")
|
(test_dir / "requirements.lock").write_text("lock", encoding="utf-8")
|
||||||
(test_dir / "dist").mkdir()
|
(test_dir / "dist").mkdir()
|
||||||
(test_dir / "dist" / "package.tar.gz").write_text("dist")
|
(test_dir / "dist" / "package.tar.gz").write_text("dist", encoding="utf-8")
|
||||||
(test_dir / "build").mkdir()
|
(test_dir / "build").mkdir()
|
||||||
(test_dir / "build" / "lib").write_text("build")
|
(test_dir / "build" / "lib").write_text("build", encoding="utf-8")
|
||||||
|
|
||||||
# Create files that should be included
|
# Create files that should be included
|
||||||
(test_dir / "main.py").write_text("main")
|
(test_dir / "main.py").write_text("main", encoding="utf-8")
|
||||||
(test_dir / "pyproject.toml").write_text("project")
|
(test_dir / "pyproject.toml").write_text("project", encoding="utf-8")
|
||||||
|
|
||||||
archive_base64 = create_package_archive(test_dir)
|
archive_base64 = create_package_archive(test_dir)
|
||||||
archive_bytes = base64.b64decode(archive_base64)
|
archive_bytes = base64.b64decode(archive_base64)
|
||||||
|
|
@ -202,9 +218,9 @@ def test_get_server_info_success(valid_server_path: str, capsys) -> None:
|
||||||
process.wait()
|
process.wait()
|
||||||
|
|
||||||
|
|
||||||
def test_get_server_info_invalid_url() -> None:
|
def test_get_server_info_invalid_url(reserved_unreachable_local_url: str) -> None:
|
||||||
"""Test that invalid URL raises ValueError."""
|
"""Test that invalid URL raises ValueError."""
|
||||||
invalid_url = "http://127.0.0.1:9999"
|
invalid_url = reserved_unreachable_local_url
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
get_server_info(invalid_url)
|
get_server_info(invalid_url)
|
||||||
|
|
@ -256,9 +272,9 @@ def test_get_required_secrets_no_secrets(valid_server_path: str) -> None:
|
||||||
process.wait()
|
process.wait()
|
||||||
|
|
||||||
|
|
||||||
def test_get_required_secrets_invalid_url() -> None:
|
def test_get_required_secrets_invalid_url(reserved_unreachable_local_url: str) -> None:
|
||||||
"""Test that invalid URL raises ValueError."""
|
"""Test that invalid URL raises ValueError."""
|
||||||
invalid_url = "http://127.0.0.1:9999"
|
invalid_url = reserved_unreachable_local_url
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
ValueError, match="Failed to extract tool secrets from /worker/tools endpoint"
|
ValueError, match="Failed to extract tool secrets from /worker/tools endpoint"
|
||||||
|
|
@ -291,3 +307,67 @@ def test_verify_server_and_get_metadata_with_debug(valid_server_path: str, capsy
|
||||||
assert server_name == "simpleserver"
|
assert server_name == "simpleserver"
|
||||||
assert server_version == "1.0.0"
|
assert server_version == "1.0.0"
|
||||||
assert "MY_SECRET_KEY" in required_secrets
|
assert "MY_SECRET_KEY" in required_secrets
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Debug-aware error messages
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@patch("arcade_cli.deploy.find_python_interpreter")
|
||||||
|
@patch("arcade_cli.deploy.subprocess.Popen")
|
||||||
|
def test_start_server_process_non_debug_message(
|
||||||
|
mock_popen: MagicMock, mock_python: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""Non-debug mode error should hint at --debug flag."""
|
||||||
|
mock_python.return_value = Path("python3")
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
mock_proc.poll.return_value = 1 # Process exited immediately
|
||||||
|
mock_popen.return_value = mock_proc
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="--debug"):
|
||||||
|
start_server_process("server.py", debug=False)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("arcade_cli.deploy.find_python_interpreter")
|
||||||
|
@patch("arcade_cli.deploy.subprocess.Popen")
|
||||||
|
def test_start_server_process_debug_message(
|
||||||
|
mock_popen: MagicMock, mock_python: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""Debug mode error should NOT tell user to run with --debug (already in debug mode)."""
|
||||||
|
mock_python.return_value = Path("python3")
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
mock_proc.poll.return_value = 1 # Process exited immediately
|
||||||
|
mock_popen.return_value = mock_proc
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
start_server_process("server.py", debug=True)
|
||||||
|
|
||||||
|
msg = str(exc_info.value)
|
||||||
|
assert "--debug" not in msg, "Debug mode error must not tell user to re-run with --debug"
|
||||||
|
assert "above" in msg.lower() or "output" in msg.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_for_health_non_debug_message(reserved_unreachable_local_url: str) -> None:
|
||||||
|
"""Non-debug health timeout should hint at --debug flag."""
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
mock_proc.communicate.return_value = (None, None)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="--debug"):
|
||||||
|
wait_for_health(reserved_unreachable_local_url, mock_proc, timeout=1, debug=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_for_health_debug_message(reserved_unreachable_local_url: str) -> None:
|
||||||
|
"""Debug health timeout should NOT tell user to run with --debug,
|
||||||
|
and SHOULD reference checking the output already shown above."""
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
mock_proc.communicate.return_value = (None, None)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
wait_for_health(reserved_unreachable_local_url, mock_proc, timeout=1, debug=True)
|
||||||
|
|
||||||
|
msg = str(exc_info.value)
|
||||||
|
assert "--debug" not in msg, "Debug mode error must not tell user to re-run with --debug"
|
||||||
|
assert "above" in msg.lower() or "output" in msg.lower(), (
|
||||||
|
f"Debug mode error should reference checking output above; got: {msg!r}"
|
||||||
|
)
|
||||||
|
|
|
||||||
336
libs/tests/cli/test_authn_callback.py
Normal file
336
libs/tests/cli/test_authn_callback.py
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
|
from arcade_cli.authn import OAuthCallbackServer, _open_browser, oauth_callback_server
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_callback_server_success() -> None:
|
||||||
|
state = "test-state"
|
||||||
|
with oauth_callback_server(state, port=0) as server:
|
||||||
|
url = f"{server.get_redirect_uri()}?code=abc123&state={state}"
|
||||||
|
with urlopen(url) as response:
|
||||||
|
assert response.status == 200
|
||||||
|
response.read()
|
||||||
|
assert server.wait_for_result(timeout=1.0) is True
|
||||||
|
|
||||||
|
assert server.result["code"] == "abc123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_callback_server_timeout() -> None:
|
||||||
|
state = "test-timeout"
|
||||||
|
with oauth_callback_server(state, port=0) as server:
|
||||||
|
assert server.wait_for_result(timeout=0.05) is False
|
||||||
|
|
||||||
|
assert "Timed out" in server.result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_callback_server_binds_to_loopback() -> None:
|
||||||
|
"""The callback server must bind to 127.0.0.1 (loopback) to avoid
|
||||||
|
Windows Firewall prompts and keep redirect host aligned with bind host."""
|
||||||
|
state = "test-bind"
|
||||||
|
with oauth_callback_server(state, port=0) as server:
|
||||||
|
assert server.httpd is not None
|
||||||
|
host, _port = server.httpd.server_address
|
||||||
|
assert host == "127.0.0.1", f"Expected 127.0.0.1 but got {host}"
|
||||||
|
# Also confirm the redirect URI host matches the bound loopback host.
|
||||||
|
redirect = server.get_redirect_uri()
|
||||||
|
assert redirect.startswith("http://127.0.0.1:")
|
||||||
|
server.shutdown_server()
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_callback_server_state_mismatch() -> None:
|
||||||
|
"""Requests with a mismatched state parameter should return an error."""
|
||||||
|
state = "correct-state"
|
||||||
|
with oauth_callback_server(state, port=0) as server:
|
||||||
|
url = f"{server.get_redirect_uri()}?code=abc&state=wrong-state"
|
||||||
|
try:
|
||||||
|
with urlopen(url) as response:
|
||||||
|
response.read()
|
||||||
|
except HTTPError:
|
||||||
|
pass # Expected — handler returns 400 for state mismatch.
|
||||||
|
server.wait_for_result(timeout=1.0)
|
||||||
|
|
||||||
|
assert "error" in server.result
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_callback_server_missing_code() -> None:
|
||||||
|
"""Requests without a code parameter should produce an error result."""
|
||||||
|
state = "no-code-state"
|
||||||
|
with oauth_callback_server(state, port=0) as server:
|
||||||
|
url = f"{server.get_redirect_uri()}?state={state}"
|
||||||
|
try:
|
||||||
|
with urlopen(url) as response:
|
||||||
|
response.read()
|
||||||
|
except HTTPError:
|
||||||
|
pass # Expected — handler returns 400 for missing code.
|
||||||
|
server.wait_for_result(timeout=1.0)
|
||||||
|
|
||||||
|
assert "error" in server.result
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_callback_server_wait_until_ready() -> None:
|
||||||
|
"""wait_until_ready() should return True once the server is listening."""
|
||||||
|
state = "ready-test"
|
||||||
|
server = OAuthCallbackServer(state, port=0)
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
t = threading.Thread(target=server.run_server, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
assert server.wait_until_ready(timeout=5.0) is True
|
||||||
|
assert server.httpd is not None
|
||||||
|
assert server.port != 0 # Ephemeral port was assigned.
|
||||||
|
|
||||||
|
server.shutdown_server()
|
||||||
|
t.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_callback_server_wait_until_ready_timeout() -> None:
|
||||||
|
"""wait_until_ready() should return False if the server never starts."""
|
||||||
|
state = "ready-timeout"
|
||||||
|
server = OAuthCallbackServer(state, port=0)
|
||||||
|
# Don't start the server — ready_event never gets set.
|
||||||
|
assert server.wait_until_ready(timeout=0.05) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_perform_oauth_login_hides_auth_url_when_browser_succeeds() -> None:
|
||||||
|
"""When browser launch succeeds, status output should not include auth URL."""
|
||||||
|
status_messages: list[str] = []
|
||||||
|
|
||||||
|
def capture_status(msg: str) -> None:
|
||||||
|
status_messages.append(msg)
|
||||||
|
|
||||||
|
# We need to mock the entire OAuth flow since we can't hit a real coordinator.
|
||||||
|
with (
|
||||||
|
patch("arcade_cli.authn.fetch_cli_config") as mock_config,
|
||||||
|
patch("arcade_cli.authn.create_oauth_client"),
|
||||||
|
patch("arcade_cli.authn.generate_authorization_url") as mock_gen_url,
|
||||||
|
patch("arcade_cli.authn._open_browser") as mock_browser,
|
||||||
|
patch("arcade_cli.authn.oauth_callback_server") as mock_server_ctx,
|
||||||
|
):
|
||||||
|
mock_config.return_value = MagicMock()
|
||||||
|
mock_gen_url.return_value = ("https://example.com/auth?state=abc", "verifier123")
|
||||||
|
mock_browser.return_value = True
|
||||||
|
|
||||||
|
mock_server = MagicMock()
|
||||||
|
mock_server.get_redirect_uri.return_value = "http://localhost:9999/callback"
|
||||||
|
mock_server.result = {"error": "timeout for test"}
|
||||||
|
mock_server.wait_for_result.return_value = False
|
||||||
|
mock_server_ctx.return_value.__enter__ = MagicMock(return_value=mock_server)
|
||||||
|
mock_server_ctx.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
from arcade_cli.authn import OAuthLoginError, perform_oauth_login
|
||||||
|
|
||||||
|
try:
|
||||||
|
perform_oauth_login(
|
||||||
|
"https://fake-coordinator.example.com",
|
||||||
|
on_status=capture_status,
|
||||||
|
callback_timeout_seconds=1,
|
||||||
|
)
|
||||||
|
except OAuthLoginError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
url_messages = [m for m in status_messages if "https://example.com/auth" in m]
|
||||||
|
assert len(url_messages) == 0, (
|
||||||
|
"Auth URL should be hidden when browser launch succeeds. "
|
||||||
|
f"Got status messages: {status_messages}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_perform_oauth_login_shows_url_when_browser_fails() -> None:
|
||||||
|
"""When _open_browser fails, the URL should still be shown."""
|
||||||
|
status_messages: list[str] = []
|
||||||
|
|
||||||
|
def capture_status(msg: str) -> None:
|
||||||
|
status_messages.append(msg)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("arcade_cli.authn.fetch_cli_config") as mock_config,
|
||||||
|
patch("arcade_cli.authn.create_oauth_client"),
|
||||||
|
patch("arcade_cli.authn.generate_authorization_url") as mock_gen_url,
|
||||||
|
patch("arcade_cli.authn._open_browser") as mock_browser,
|
||||||
|
patch("arcade_cli.authn.oauth_callback_server") as mock_server_ctx,
|
||||||
|
):
|
||||||
|
mock_config.return_value = MagicMock()
|
||||||
|
mock_gen_url.return_value = ("https://example.com/auth?state=xyz", "verifier456")
|
||||||
|
mock_browser.return_value = False # Browser failed
|
||||||
|
|
||||||
|
mock_server = MagicMock()
|
||||||
|
mock_server.get_redirect_uri.return_value = "http://localhost:9999/callback"
|
||||||
|
mock_server.result = {"error": "timeout for test"}
|
||||||
|
mock_server.wait_for_result.return_value = False
|
||||||
|
mock_server_ctx.return_value.__enter__ = MagicMock(return_value=mock_server)
|
||||||
|
mock_server_ctx.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
from arcade_cli.authn import OAuthLoginError, perform_oauth_login
|
||||||
|
|
||||||
|
try:
|
||||||
|
perform_oauth_login(
|
||||||
|
"https://fake-coordinator.example.com",
|
||||||
|
on_status=capture_status,
|
||||||
|
callback_timeout_seconds=1,
|
||||||
|
)
|
||||||
|
except OAuthLoginError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
url_messages = [m for m in status_messages if "https://example.com/auth" in m]
|
||||||
|
assert len(url_messages) >= 1
|
||||||
|
assert any("Open this link to log in" in m for m in status_messages)
|
||||||
|
# When browser fails, the message should say "Could not open a browser"
|
||||||
|
browser_fail_msgs = [m for m in status_messages if "Could not open a browser" in m]
|
||||||
|
assert len(browser_fail_msgs) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_perform_oauth_login_timeout_clamps_negative() -> None:
|
||||||
|
"""Negative --timeout values should be clamped to the default."""
|
||||||
|
from arcade_cli.authn import DEFAULT_OAUTH_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
status_messages: list[str] = []
|
||||||
|
|
||||||
|
def capture_status(msg: str) -> None:
|
||||||
|
status_messages.append(msg)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("arcade_cli.authn.fetch_cli_config") as mock_config,
|
||||||
|
patch("arcade_cli.authn.create_oauth_client"),
|
||||||
|
patch("arcade_cli.authn.generate_authorization_url") as mock_gen_url,
|
||||||
|
patch("arcade_cli.authn._open_browser") as mock_browser,
|
||||||
|
patch("arcade_cli.authn.oauth_callback_server") as mock_server_ctx,
|
||||||
|
):
|
||||||
|
mock_config.return_value = MagicMock()
|
||||||
|
mock_gen_url.return_value = ("https://example.com/auth", "v")
|
||||||
|
mock_browser.return_value = True
|
||||||
|
|
||||||
|
mock_server = MagicMock()
|
||||||
|
mock_server.get_redirect_uri.return_value = "http://localhost:9999/callback"
|
||||||
|
mock_server.result = {"error": "timeout"}
|
||||||
|
mock_server.wait_for_result.return_value = False
|
||||||
|
mock_server_ctx.return_value.__enter__ = MagicMock(return_value=mock_server)
|
||||||
|
mock_server_ctx.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
from arcade_cli.authn import OAuthLoginError, perform_oauth_login
|
||||||
|
|
||||||
|
try:
|
||||||
|
perform_oauth_login(
|
||||||
|
"https://fake.example.com",
|
||||||
|
on_status=capture_status,
|
||||||
|
callback_timeout_seconds=-5,
|
||||||
|
)
|
||||||
|
except OAuthLoginError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# The timeout message should show the default, not -5.
|
||||||
|
timeout_msgs = [m for m in status_messages if "timeout:" in m.lower()]
|
||||||
|
assert any(str(DEFAULT_OAUTH_TIMEOUT_SECONDS) in m for m in timeout_msgs), (
|
||||||
|
f"Expected default timeout {DEFAULT_OAUTH_TIMEOUT_SECONDS} in messages: {timeout_msgs}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _open_browser() — CMD-window suppression on Windows
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpenBrowser:
|
||||||
|
"""Tests for the _open_browser helper that suppresses CMD flash on Windows.
|
||||||
|
|
||||||
|
On Windows the priority order is:
|
||||||
|
1. ctypes ShellExecuteW (direct Win32 API, no console)
|
||||||
|
2. rundll32 url.dll (GUI binary, no console)
|
||||||
|
3. webbrowser.open (stdlib fallback)
|
||||||
|
|
||||||
|
os.startfile is intentionally omitted: it is a thin CPython wrapper around
|
||||||
|
ShellExecuteExW, making it redundant with step 1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_delegates_to_webbrowser_on_non_windows(self) -> None:
|
||||||
|
"""On non-Windows, _open_browser should use webbrowser.open."""
|
||||||
|
with (
|
||||||
|
patch.object(sys, "platform", "linux"),
|
||||||
|
patch("arcade_cli.authn.webbrowser") as mock_wb,
|
||||||
|
):
|
||||||
|
mock_wb.open.return_value = True
|
||||||
|
result = _open_browser("https://example.com")
|
||||||
|
assert result is True
|
||||||
|
mock_wb.open.assert_called_once_with("https://example.com")
|
||||||
|
|
||||||
|
def test_tries_ctypes_shellexecute_first_on_windows(self) -> None:
|
||||||
|
"""On Windows, _open_browser should try ctypes ShellExecuteW first."""
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
# On non-Windows, ctypes.windll doesn't exist; provide a mock
|
||||||
|
mock_shell32 = MagicMock()
|
||||||
|
mock_shell32.ShellExecuteW = MagicMock(return_value=42)
|
||||||
|
mock_windll = MagicMock()
|
||||||
|
mock_windll.shell32 = mock_shell32
|
||||||
|
|
||||||
|
with patch.object(sys, "platform", "win32"), patch.object(
|
||||||
|
ctypes, "windll", mock_windll, create=True
|
||||||
|
):
|
||||||
|
result = _open_browser("https://example.com")
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_falls_back_to_rundll32_on_windows(self) -> None:
|
||||||
|
"""If ctypes fails, try rundll32 url.dll."""
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
mock_shell32 = MagicMock()
|
||||||
|
mock_shell32.ShellExecuteW = MagicMock(side_effect=Exception("ctypes failed"))
|
||||||
|
mock_windll = MagicMock()
|
||||||
|
mock_windll.shell32 = mock_shell32
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(ctypes, "windll", mock_windll, create=True),
|
||||||
|
patch("arcade_cli.authn.subprocess.Popen") as mock_popen,
|
||||||
|
patch("arcade_cli.authn.subprocess.STARTUPINFO", create=True) as mock_si_cls,
|
||||||
|
patch("arcade_cli.authn.subprocess.STARTF_USESHOWWINDOW", 1, create=True),
|
||||||
|
patch("arcade_cli.authn.subprocess.DEVNULL", subprocess.DEVNULL),
|
||||||
|
):
|
||||||
|
mock_si = MagicMock()
|
||||||
|
mock_si.dwFlags = 0
|
||||||
|
mock_si_cls.return_value = mock_si
|
||||||
|
mock_popen.return_value = MagicMock()
|
||||||
|
|
||||||
|
result = _open_browser("https://example.com")
|
||||||
|
assert result is True
|
||||||
|
mock_popen.assert_called_once()
|
||||||
|
cmd = mock_popen.call_args[0][0]
|
||||||
|
assert cmd[0] == "rundll32"
|
||||||
|
|
||||||
|
def test_falls_back_to_webbrowser_if_all_else_fails_on_windows(self) -> None:
|
||||||
|
"""If ctypes and rundll32 both fail, use webbrowser.open (step 3)."""
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
mock_shell32 = MagicMock()
|
||||||
|
mock_shell32.ShellExecuteW = MagicMock(side_effect=Exception("ctypes failed"))
|
||||||
|
mock_windll = MagicMock()
|
||||||
|
mock_windll.shell32 = mock_shell32
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(ctypes, "windll", mock_windll, create=True),
|
||||||
|
patch("arcade_cli.authn.subprocess.Popen", side_effect=Exception("fail")),
|
||||||
|
patch("arcade_cli.authn.subprocess.STARTUPINFO", create=True, return_value=MagicMock()),
|
||||||
|
patch("arcade_cli.authn.subprocess.STARTF_USESHOWWINDOW", 1, create=True),
|
||||||
|
patch("arcade_cli.authn.subprocess.DEVNULL", -1),
|
||||||
|
patch("arcade_cli.authn.webbrowser") as mock_wb,
|
||||||
|
):
|
||||||
|
mock_wb.open.return_value = True
|
||||||
|
result = _open_browser("https://example.com")
|
||||||
|
assert result is True
|
||||||
|
mock_wb.open.assert_called_once()
|
||||||
|
|
||||||
|
def test_returns_false_if_everything_fails(self) -> None:
|
||||||
|
"""If all methods fail, _open_browser should return False."""
|
||||||
|
with (
|
||||||
|
patch.object(sys, "platform", "linux"),
|
||||||
|
patch("arcade_cli.authn.webbrowser") as mock_wb,
|
||||||
|
):
|
||||||
|
mock_wb.open.side_effect = Exception("no browser")
|
||||||
|
result = _open_browser("https://example.com")
|
||||||
|
assert result is False
|
||||||
484
libs/tests/cli/test_configure.py
Normal file
484
libs/tests/cli/test_configure.py
Normal file
|
|
@ -0,0 +1,484 @@
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from io import StringIO
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_cli.configure import (
|
||||||
|
_format_path_for_display,
|
||||||
|
_resolve_windows_appdata,
|
||||||
|
_warn_overwrite,
|
||||||
|
configure_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_entrypoint(tmp_path: Path) -> Path:
|
||||||
|
entrypoint = tmp_path / "server.py"
|
||||||
|
entrypoint.write_text("print('ok')\n", encoding="utf-8")
|
||||||
|
return entrypoint
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(path: Path) -> dict:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_stdio_entry(entry: dict) -> None:
|
||||||
|
assert "command" in entry
|
||||||
|
assert "args" in entry
|
||||||
|
assert any(str(arg).endswith("server.py") for arg in entry["args"])
|
||||||
|
assert "env" in entry
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _format_path_for_display()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_path_for_display_windows_quotes() -> None:
|
||||||
|
path = Path(r"C:\Users\A User\My Server\mcp.json")
|
||||||
|
assert (
|
||||||
|
_format_path_for_display(path, platform_system="Windows")
|
||||||
|
== '"C:\\Users\\A User\\My Server\\mcp.json"'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_path_for_display_no_spaces_unchanged() -> None:
|
||||||
|
"""Paths without spaces should be returned as-is."""
|
||||||
|
path = Path(r"C:\Users\Alice\mcp.json")
|
||||||
|
result = _format_path_for_display(path, platform_system="Windows")
|
||||||
|
assert result == str(path)
|
||||||
|
assert '"' not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_path_for_display_posix_escapes() -> None:
|
||||||
|
# Use str directly to avoid Windows Path normalization converting / to \
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# On Windows, Path("/tmp/with space/mcp.json") uses backslashes.
|
||||||
|
# The function should still escape spaces.
|
||||||
|
path = Path("/tmp/with space/mcp.json")
|
||||||
|
result = _format_path_for_display(path, platform_system="Linux")
|
||||||
|
assert "\\ " in result # spaces are escaped
|
||||||
|
else:
|
||||||
|
path = Path("/tmp/with space/mcp.json")
|
||||||
|
assert (
|
||||||
|
_format_path_for_display(path, platform_system="Linux")
|
||||||
|
== "/tmp/with\\ space/mcp.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _resolve_windows_appdata()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_windows_appdata_delegates_to_platformdirs(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""_resolve_windows_appdata returns whatever platformdirs resolves."""
|
||||||
|
monkeypatch.delenv("APPDATA", raising=False)
|
||||||
|
monkeypatch.delenv("LOCALAPPDATA", raising=False)
|
||||||
|
monkeypatch.delenv("USERPROFILE", raising=False)
|
||||||
|
|
||||||
|
fake_platformdirs = types.ModuleType("platformdirs")
|
||||||
|
fake_platformdirs.user_data_dir = (
|
||||||
|
lambda *args, **kwargs: r"C:\Users\Alice\AppData\Roaming"
|
||||||
|
)
|
||||||
|
monkeypatch.setitem(sys.modules, "platformdirs", fake_platformdirs)
|
||||||
|
|
||||||
|
assert _resolve_windows_appdata() == Path(r"C:\Users\Alice\AppData\Roaming")
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_windows_appdata_handles_older_platformdirs(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Falls back to positional args when platformdirs raises TypeError.
|
||||||
|
|
||||||
|
The positional signature is user_data_dir(appname, appauthor, version, roaming).
|
||||||
|
The fallback call must pass roaming=True as the *fourth* positional arg, not
|
||||||
|
the third (which would be ``version``).
|
||||||
|
"""
|
||||||
|
received_args: list[tuple] = []
|
||||||
|
|
||||||
|
def strict_user_data_dir(*args: object, **kwargs: object) -> str:
|
||||||
|
if kwargs:
|
||||||
|
raise TypeError("keyword args not supported")
|
||||||
|
received_args.append(args)
|
||||||
|
return r"C:\Users\Bob\AppData\Roaming"
|
||||||
|
|
||||||
|
fake_platformdirs = types.ModuleType("platformdirs")
|
||||||
|
fake_platformdirs.user_data_dir = strict_user_data_dir
|
||||||
|
monkeypatch.setitem(sys.modules, "platformdirs", fake_platformdirs)
|
||||||
|
|
||||||
|
result = _resolve_windows_appdata()
|
||||||
|
assert result == Path(r"C:\Users\Bob\AppData\Roaming")
|
||||||
|
|
||||||
|
# First call raises TypeError (has kwargs), second call uses positional args.
|
||||||
|
# Verify the fallback used the correct signature: (appname, appauthor, version, roaming)
|
||||||
|
assert len(received_args) == 1, "Fallback must make exactly one positional call"
|
||||||
|
fallback_args = received_args[0]
|
||||||
|
# args: (None, False, None, True) — roaming is the 4th positional arg
|
||||||
|
assert len(fallback_args) == 4, f"Expected 4 positional args, got {len(fallback_args)}: {fallback_args}"
|
||||||
|
assert fallback_args[3] is True, f"4th arg (roaming) must be True, got {fallback_args[3]}"
|
||||||
|
assert fallback_args[2] is None, f"3rd arg (version) must be None, got {fallback_args[2]}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_cursor_config_path_windows_prefers_existing_candidate(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
import arcade_cli.configure as configure_mod
|
||||||
|
|
||||||
|
appdata_path = tmp_path / "AppData" / "Roaming" / "Cursor" / "mcp.json"
|
||||||
|
home_path = tmp_path / ".cursor" / "mcp.json"
|
||||||
|
home_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
home_path.write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setattr(configure_mod.platform, "system", lambda: "Windows")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
configure_mod,
|
||||||
|
"_get_windows_cursor_config_paths",
|
||||||
|
lambda: [appdata_path, home_path],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert configure_mod.get_cursor_config_path() == home_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _warn_overwrite()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_warn_overwrite_prints_when_entry_exists() -> None:
|
||||||
|
"""Should print a yellow warning when the server entry already exists."""
|
||||||
|
from arcade_cli.console import Console
|
||||||
|
|
||||||
|
buf = StringIO()
|
||||||
|
test_console = Console(file=buf, force_terminal=False)
|
||||||
|
|
||||||
|
import arcade_cli.configure as configure_mod
|
||||||
|
|
||||||
|
orig = configure_mod.console
|
||||||
|
configure_mod.console = test_console
|
||||||
|
try:
|
||||||
|
config = {"mcpServers": {"demo": {"command": "old"}}}
|
||||||
|
_warn_overwrite(config, "mcpServers", "demo", Path("/fake/cursor.json"))
|
||||||
|
finally:
|
||||||
|
configure_mod.console = orig
|
||||||
|
|
||||||
|
output = buf.getvalue()
|
||||||
|
assert "demo" in output
|
||||||
|
assert "already exists" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_warn_overwrite_silent_when_no_entry() -> None:
|
||||||
|
"""Should NOT print anything when the server entry doesn't exist."""
|
||||||
|
from arcade_cli.console import Console
|
||||||
|
|
||||||
|
buf = StringIO()
|
||||||
|
test_console = Console(file=buf, force_terminal=True)
|
||||||
|
|
||||||
|
# Temporarily monkey-patch the module-level console used by _warn_overwrite.
|
||||||
|
import arcade_cli.configure as configure_mod
|
||||||
|
|
||||||
|
orig = configure_mod.console
|
||||||
|
configure_mod.console = test_console
|
||||||
|
try:
|
||||||
|
config: dict = {"mcpServers": {}}
|
||||||
|
_warn_overwrite(config, "mcpServers", "new_server", Path("/fake/mcp.json"))
|
||||||
|
finally:
|
||||||
|
configure_mod.console = orig
|
||||||
|
|
||||||
|
assert buf.getvalue() == "", "No output expected when entry doesn't exist"
|
||||||
|
|
||||||
|
|
||||||
|
def test_warn_overwrite_message_content() -> None:
|
||||||
|
"""Verify the warning message mentions the server name."""
|
||||||
|
from arcade_cli.console import Console
|
||||||
|
|
||||||
|
buf = StringIO()
|
||||||
|
test_console = Console(file=buf, force_terminal=False)
|
||||||
|
|
||||||
|
import arcade_cli.configure as configure_mod
|
||||||
|
|
||||||
|
orig = configure_mod.console
|
||||||
|
configure_mod.console = test_console
|
||||||
|
try:
|
||||||
|
config = {"servers": {"my_srv": {"command": "old"}}}
|
||||||
|
_warn_overwrite(config, "servers", "my_srv", Path("/fake/vscode.json"))
|
||||||
|
finally:
|
||||||
|
configure_mod.console = orig
|
||||||
|
|
||||||
|
output = buf.getvalue()
|
||||||
|
assert "my_srv" in output
|
||||||
|
assert "already exists" in output
|
||||||
|
assert "--name" in output
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# UTF-8 config I/O
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_written_as_utf8(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Config files must be written with UTF-8 encoding, including non-ASCII paths."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
_write_entrypoint(tmp_path)
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
|
||||||
|
configure_client(
|
||||||
|
client="cursor",
|
||||||
|
entrypoint_file="server.py",
|
||||||
|
server_name="demo",
|
||||||
|
transport="stdio",
|
||||||
|
host="local",
|
||||||
|
port=8000,
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read the file as raw bytes and verify UTF-8 BOM is absent and content
|
||||||
|
# decodes cleanly as UTF-8.
|
||||||
|
raw = config_path.read_bytes()
|
||||||
|
assert not raw.startswith(b"\xef\xbb\xbf"), "UTF-8 BOM should not be present"
|
||||||
|
decoded = raw.decode("utf-8") # Should not raise
|
||||||
|
data = json.loads(decoded)
|
||||||
|
assert "mcpServers" in data
|
||||||
|
assert "demo" in data["mcpServers"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_roundtrip_preserves_unicode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Write a config with Unicode, then overwrite and verify it still decodes."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
_write_entrypoint(tmp_path)
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
|
||||||
|
# Seed with Unicode content
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps({"mcpServers": {"caf\u00e9": {"command": "old"}}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
configure_client(
|
||||||
|
client="cursor",
|
||||||
|
entrypoint_file="server.py",
|
||||||
|
server_name="demo",
|
||||||
|
transport="stdio",
|
||||||
|
host="local",
|
||||||
|
port=8000,
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
# Original Unicode entry should be preserved alongside the new one.
|
||||||
|
assert "caf\u00e9" in data["mcpServers"]
|
||||||
|
assert "demo" in data["mcpServers"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursor_config_stdio_and_http(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
_write_entrypoint(tmp_path)
|
||||||
|
config_path = tmp_path / "cursor.json"
|
||||||
|
|
||||||
|
configure_client(
|
||||||
|
client="cursor",
|
||||||
|
entrypoint_file="server.py",
|
||||||
|
server_name="demo",
|
||||||
|
transport="stdio",
|
||||||
|
host="local",
|
||||||
|
port=8000,
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
|
config = _load_json(config_path)
|
||||||
|
entry = config["mcpServers"]["demo"]
|
||||||
|
_assert_stdio_entry(entry)
|
||||||
|
|
||||||
|
configure_client(
|
||||||
|
client="cursor",
|
||||||
|
entrypoint_file="server.py",
|
||||||
|
server_name="demo",
|
||||||
|
transport="http",
|
||||||
|
host="local",
|
||||||
|
port=8123,
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
|
config = _load_json(config_path)
|
||||||
|
entry = config["mcpServers"]["demo"]
|
||||||
|
assert entry["type"] == "stream"
|
||||||
|
assert entry["url"] == "http://localhost:8123/mcp"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursor_config_stdio_uses_absolute_uv_path(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
import arcade_cli.configure as configure_mod
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
_write_entrypoint(tmp_path)
|
||||||
|
config_path = tmp_path / "cursor.json"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
configure_mod.shutil,
|
||||||
|
"which",
|
||||||
|
lambda executable: r"C:\Tools\uv.exe" if executable == "uv" else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
configure_client(
|
||||||
|
client="cursor",
|
||||||
|
entrypoint_file="server.py",
|
||||||
|
server_name="demo",
|
||||||
|
transport="stdio",
|
||||||
|
host="local",
|
||||||
|
port=8000,
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
config = _load_json(config_path)
|
||||||
|
assert config["mcpServers"]["demo"]["command"] == r"C:\Tools\uv.exe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursor_windows_writes_compatibility_paths(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
import arcade_cli.configure as configure_mod
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
_write_entrypoint(tmp_path)
|
||||||
|
|
||||||
|
appdata_path = tmp_path / "AppData" / "Roaming" / "Cursor" / "mcp.json"
|
||||||
|
home_path = tmp_path / ".cursor" / "mcp.json"
|
||||||
|
appdata_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
home_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
appdata_path.write_text(
|
||||||
|
json.dumps({"mcpServers": {"appdata_only": {"command": "x"}}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
home_path.write_text(
|
||||||
|
json.dumps({"mcpServers": {"home_only": {"command": "y"}}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(configure_mod.platform, "system", lambda: "Windows")
|
||||||
|
monkeypatch.setattr(configure_mod, "get_cursor_config_path", lambda: appdata_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
configure_mod,
|
||||||
|
"_get_windows_cursor_config_paths",
|
||||||
|
lambda: [appdata_path, home_path],
|
||||||
|
)
|
||||||
|
|
||||||
|
configure_client(
|
||||||
|
client="cursor",
|
||||||
|
entrypoint_file="server.py",
|
||||||
|
server_name="demo",
|
||||||
|
transport="stdio",
|
||||||
|
host="local",
|
||||||
|
port=8000,
|
||||||
|
)
|
||||||
|
|
||||||
|
appdata_config = _load_json(appdata_path)
|
||||||
|
home_config = _load_json(home_path)
|
||||||
|
assert "demo" in appdata_config["mcpServers"]
|
||||||
|
assert "demo" in home_config["mcpServers"]
|
||||||
|
assert "appdata_only" in appdata_config["mcpServers"]
|
||||||
|
assert "home_only" in home_config["mcpServers"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursor_windows_explicit_config_does_not_write_compatibility_paths(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
import arcade_cli.configure as configure_mod
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
_write_entrypoint(tmp_path)
|
||||||
|
|
||||||
|
explicit_path = tmp_path / "custom" / "cursor.json"
|
||||||
|
appdata_path = tmp_path / "AppData" / "Roaming" / "Cursor" / "mcp.json"
|
||||||
|
home_path = tmp_path / ".cursor" / "mcp.json"
|
||||||
|
|
||||||
|
monkeypatch.setattr(configure_mod.platform, "system", lambda: "Windows")
|
||||||
|
monkeypatch.setattr(configure_mod, "get_cursor_config_path", lambda: appdata_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
configure_mod,
|
||||||
|
"_get_windows_cursor_config_paths",
|
||||||
|
lambda: [appdata_path, home_path],
|
||||||
|
)
|
||||||
|
|
||||||
|
configure_client(
|
||||||
|
client="cursor",
|
||||||
|
entrypoint_file="server.py",
|
||||||
|
server_name="demo",
|
||||||
|
transport="stdio",
|
||||||
|
host="local",
|
||||||
|
port=8000,
|
||||||
|
config_path=explicit_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert explicit_path.exists()
|
||||||
|
assert not appdata_path.exists()
|
||||||
|
assert not home_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_vscode_config_stdio_and_http(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
_write_entrypoint(tmp_path)
|
||||||
|
config_path = tmp_path / "vscode.json"
|
||||||
|
|
||||||
|
configure_client(
|
||||||
|
client="vscode",
|
||||||
|
entrypoint_file="server.py",
|
||||||
|
server_name="demo",
|
||||||
|
transport="stdio",
|
||||||
|
host="local",
|
||||||
|
port=8000,
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
|
config = _load_json(config_path)
|
||||||
|
entry = config["servers"]["demo"]
|
||||||
|
_assert_stdio_entry(entry)
|
||||||
|
|
||||||
|
configure_client(
|
||||||
|
client="vscode",
|
||||||
|
entrypoint_file="server.py",
|
||||||
|
server_name="demo",
|
||||||
|
transport="http",
|
||||||
|
host="local",
|
||||||
|
port=8123,
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
|
config = _load_json(config_path)
|
||||||
|
entry = config["servers"]["demo"]
|
||||||
|
assert entry["type"] == "http"
|
||||||
|
assert entry["url"] == "http://localhost:8123/mcp"
|
||||||
|
|
||||||
|
|
||||||
|
def test_claude_config_stdio_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
_write_entrypoint(tmp_path)
|
||||||
|
config_path = tmp_path / "claude.json"
|
||||||
|
|
||||||
|
configure_client(
|
||||||
|
client="claude",
|
||||||
|
entrypoint_file="server.py",
|
||||||
|
server_name="demo",
|
||||||
|
transport="stdio",
|
||||||
|
host="local",
|
||||||
|
port=8000,
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
|
config = _load_json(config_path)
|
||||||
|
entry = config["mcpServers"]["demo"]
|
||||||
|
_assert_stdio_entry(entry)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Claude Desktop only supports stdio"):
|
||||||
|
configure_client(
|
||||||
|
client="claude",
|
||||||
|
entrypoint_file="server.py",
|
||||||
|
server_name="demo",
|
||||||
|
transport="http",
|
||||||
|
host="local",
|
||||||
|
port=8000,
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
161
libs/tests/cli/test_console_encoding.py
Normal file
161
libs/tests/cli/test_console_encoding.py
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
"""Tests for the console.py encoding safety layer.
|
||||||
|
|
||||||
|
These tests verify that _needs_utf8() and _configure_windows_utf8()
|
||||||
|
behave correctly and do not crash, even when the console encoding
|
||||||
|
would be cp1252 (the default on many Western-European Windows installs).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_cli.console import _configure_windows_utf8, _needs_utf8
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _needs_utf8()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNeedsUtf8:
|
||||||
|
"""Unit tests for _needs_utf8()."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"encoding, expected",
|
||||||
|
[
|
||||||
|
("utf-8", False),
|
||||||
|
("UTF-8", False),
|
||||||
|
("utf8", False),
|
||||||
|
("UTF8", False),
|
||||||
|
("cp1252", True),
|
||||||
|
("ascii", True),
|
||||||
|
("latin-1", True),
|
||||||
|
("", True),
|
||||||
|
(None, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_known_encodings(self, encoding: str | None, expected: bool) -> None:
|
||||||
|
assert _needs_utf8(encoding) is expected
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _configure_windows_utf8()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigureWindowsUtf8:
|
||||||
|
"""Tests for _configure_windows_utf8()."""
|
||||||
|
|
||||||
|
def test_noop_on_non_windows(self) -> None:
|
||||||
|
"""On non-Windows platforms the function should be a no-op."""
|
||||||
|
with patch.object(sys, "platform", "linux"):
|
||||||
|
# Should not raise and not change anything.
|
||||||
|
_configure_windows_utf8()
|
||||||
|
|
||||||
|
def test_reconfigures_when_cp1252(self) -> None:
|
||||||
|
"""Simulate a cp1252 stdout on 'win32' and verify reconfigure is called."""
|
||||||
|
fake_stdout = io.TextIOWrapper(io.BytesIO(), encoding="cp1252")
|
||||||
|
fake_stderr = io.TextIOWrapper(io.BytesIO(), encoding="cp1252")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(sys, "stdout", fake_stdout),
|
||||||
|
patch.object(sys, "stderr", fake_stderr),
|
||||||
|
):
|
||||||
|
_configure_windows_utf8()
|
||||||
|
|
||||||
|
# After reconfiguration the streams should be utf-8.
|
||||||
|
assert fake_stdout.encoding.lower().replace("-", "") == "utf8"
|
||||||
|
assert fake_stderr.encoding.lower().replace("-", "") == "utf8"
|
||||||
|
|
||||||
|
def test_sets_pythonioencoding_env(self) -> None:
|
||||||
|
"""When reconfiguring, PYTHONIOENCODING should be set as a fallback."""
|
||||||
|
fake_stdout = io.TextIOWrapper(io.BytesIO(), encoding="cp1252")
|
||||||
|
fake_stderr = io.TextIOWrapper(io.BytesIO(), encoding="cp1252")
|
||||||
|
|
||||||
|
env_copy = os.environ.copy()
|
||||||
|
env_copy.pop("PYTHONIOENCODING", None)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(sys, "stdout", fake_stdout),
|
||||||
|
patch.object(sys, "stderr", fake_stderr),
|
||||||
|
patch.dict(os.environ, env_copy, clear=True),
|
||||||
|
):
|
||||||
|
_configure_windows_utf8()
|
||||||
|
assert os.environ.get("PYTHONIOENCODING") == "utf-8"
|
||||||
|
|
||||||
|
def test_does_not_override_existing_pythonioencoding(self) -> None:
|
||||||
|
"""If PYTHONIOENCODING is already set, don't overwrite it."""
|
||||||
|
fake_stdout = io.TextIOWrapper(io.BytesIO(), encoding="cp1252")
|
||||||
|
fake_stderr = io.TextIOWrapper(io.BytesIO(), encoding="cp1252")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(sys, "stdout", fake_stdout),
|
||||||
|
patch.object(sys, "stderr", fake_stderr),
|
||||||
|
patch.dict(os.environ, {"PYTHONIOENCODING": "ascii"}, clear=False),
|
||||||
|
):
|
||||||
|
_configure_windows_utf8()
|
||||||
|
# Should keep the existing value.
|
||||||
|
assert os.environ["PYTHONIOENCODING"] == "ascii"
|
||||||
|
|
||||||
|
def test_no_crash_when_reconfigure_missing(self) -> None:
|
||||||
|
"""Streams without a reconfigure method should not crash."""
|
||||||
|
|
||||||
|
class FakeStream:
|
||||||
|
encoding = "cp1252"
|
||||||
|
# No reconfigure attribute.
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(sys, "stdout", FakeStream()),
|
||||||
|
patch.object(sys, "stderr", FakeStream()),
|
||||||
|
):
|
||||||
|
# Should not raise.
|
||||||
|
_configure_windows_utf8()
|
||||||
|
|
||||||
|
def test_noop_when_already_utf8(self) -> None:
|
||||||
|
"""If both streams are already utf-8, nothing should be reconfigured."""
|
||||||
|
fake_stdout = io.TextIOWrapper(io.BytesIO(), encoding="utf-8")
|
||||||
|
fake_stderr = io.TextIOWrapper(io.BytesIO(), encoding="utf-8")
|
||||||
|
|
||||||
|
reconfigure_called = False
|
||||||
|
original_reconfigure = fake_stdout.reconfigure
|
||||||
|
|
||||||
|
def tracking_reconfigure(**kwargs):
|
||||||
|
nonlocal reconfigure_called
|
||||||
|
reconfigure_called = True
|
||||||
|
return original_reconfigure(**kwargs)
|
||||||
|
|
||||||
|
fake_stdout.reconfigure = tracking_reconfigure # type: ignore[assignment]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(sys, "stdout", fake_stdout),
|
||||||
|
patch.object(sys, "stderr", fake_stderr),
|
||||||
|
):
|
||||||
|
_configure_windows_utf8()
|
||||||
|
assert not reconfigure_called, "reconfigure should not be called when already utf-8"
|
||||||
|
|
||||||
|
def test_emoji_output_after_reconfigure(self) -> None:
|
||||||
|
"""After reconfiguring a cp1252 stream, writing emoji should not crash."""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fake_stdout = io.TextIOWrapper(buf, encoding="cp1252")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(sys, "stdout", fake_stdout),
|
||||||
|
patch.object(sys, "stderr", fake_stdout),
|
||||||
|
):
|
||||||
|
_configure_windows_utf8()
|
||||||
|
# Now write emoji — should not raise UnicodeEncodeError.
|
||||||
|
fake_stdout.write("Hello! \u2705 \U0001f680 Done.\n")
|
||||||
|
fake_stdout.flush()
|
||||||
|
|
||||||
|
output = buf.getvalue().decode("utf-8")
|
||||||
|
assert "\u2705" in output
|
||||||
|
assert "\U0001f680" in output
|
||||||
211
libs/tests/cli/test_cross_platform.py
Normal file
211
libs/tests/cli/test_cross_platform.py
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
"""Focused cross-platform regression tests with minimal duplication.
|
||||||
|
|
||||||
|
This module keeps only scenarios that are not already covered by dedicated
|
||||||
|
test files under ``libs/tests/cli``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from io import StringIO
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestUtf8FileIO:
|
||||||
|
"""Verify UTF-8 behavior across file I/O paths that have regressed before."""
|
||||||
|
|
||||||
|
def test_config_model_save_uses_utf8(self, tmp_path: Path) -> None:
|
||||||
|
from arcade_core.config_model import Config
|
||||||
|
|
||||||
|
config_dir = tmp_path / ".arcade"
|
||||||
|
config_dir.mkdir()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
Config,
|
||||||
|
"get_config_file_path",
|
||||||
|
return_value=config_dir / "credentials.yaml",
|
||||||
|
),
|
||||||
|
patch.object(Config, "ensure_config_dir_exists"),
|
||||||
|
):
|
||||||
|
cfg = Config(coordinator_url="https://café-coordinator.example.com")
|
||||||
|
cfg.save_to_file()
|
||||||
|
|
||||||
|
raw = (config_dir / "credentials.yaml").read_bytes()
|
||||||
|
text = raw.decode("utf-8")
|
||||||
|
assert "caf" in text
|
||||||
|
assert "é" in text or "\\xe9" not in text
|
||||||
|
|
||||||
|
def test_config_model_load_reads_utf8(self, tmp_path: Path) -> None:
|
||||||
|
import yaml
|
||||||
|
from arcade_core.config_model import Config
|
||||||
|
|
||||||
|
config_dir = tmp_path / ".arcade"
|
||||||
|
config_dir.mkdir()
|
||||||
|
config_file = config_dir / "credentials.yaml"
|
||||||
|
|
||||||
|
data = {"cloud": {"coordinator_url": "https://café.example.com"}}
|
||||||
|
config_file.write_text(yaml.dump(data), encoding="utf-8")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(Config, "get_config_file_path", return_value=config_file),
|
||||||
|
patch.object(Config, "ensure_config_dir_exists"),
|
||||||
|
):
|
||||||
|
loaded = Config.load_from_file()
|
||||||
|
|
||||||
|
assert loaded.coordinator_url == "https://café.example.com"
|
||||||
|
|
||||||
|
def test_config_model_permissions_no_crash_on_windows(self, tmp_path: Path) -> None:
|
||||||
|
from arcade_core.config_model import Config
|
||||||
|
|
||||||
|
config_dir = tmp_path / ".arcade"
|
||||||
|
config_dir.mkdir()
|
||||||
|
config_file = config_dir / "credentials.yaml"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(Config, "get_config_file_path", return_value=config_file),
|
||||||
|
patch.object(Config, "ensure_config_dir_exists"),
|
||||||
|
patch("arcade_core.config_model.os.name", "nt"),
|
||||||
|
patch("arcade_core.config_model.subprocess.run", side_effect=OSError("icacls failed")),
|
||||||
|
):
|
||||||
|
cfg = Config(coordinator_url="https://test.example.com")
|
||||||
|
cfg.save_to_file()
|
||||||
|
|
||||||
|
assert config_file.exists()
|
||||||
|
|
||||||
|
def test_configure_client_writes_utf8(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
from arcade_cli.configure import configure_client
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
entrypoint = tmp_path / "server.py"
|
||||||
|
entrypoint.write_text("print('ok')\n", encoding="utf-8")
|
||||||
|
|
||||||
|
config_path = tmp_path / "test_config.json"
|
||||||
|
configure_client(
|
||||||
|
client="cursor",
|
||||||
|
entrypoint_file="server.py",
|
||||||
|
server_name="café-server",
|
||||||
|
transport="stdio",
|
||||||
|
host="local",
|
||||||
|
port=8000,
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = config_path.read_bytes()
|
||||||
|
assert not raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8")
|
||||||
|
data = json.loads(text)
|
||||||
|
assert "café-server" in data["mcpServers"]
|
||||||
|
|
||||||
|
def test_load_env_file_reads_utf8(self, tmp_path: Path) -> None:
|
||||||
|
from arcade_cli.secret import load_env_file
|
||||||
|
|
||||||
|
env_file = tmp_path / ".env"
|
||||||
|
env_file.write_text("KEY1=café\nKEY2=naïve\n", encoding="utf-8")
|
||||||
|
|
||||||
|
secrets = load_env_file(str(env_file))
|
||||||
|
assert secrets["KEY1"] == "café"
|
||||||
|
assert secrets["KEY2"] == "naïve"
|
||||||
|
|
||||||
|
def test_identity_write_atomic_uses_utf8(self, tmp_path: Path) -> None:
|
||||||
|
from arcade_core.usage.identity import UsageIdentity
|
||||||
|
|
||||||
|
config_path = tmp_path / ".arcade"
|
||||||
|
config_path.mkdir()
|
||||||
|
|
||||||
|
with patch("arcade_core.usage.identity.ARCADE_CONFIG_PATH", str(config_path)):
|
||||||
|
identity = UsageIdentity()
|
||||||
|
identity.usage_file_path = str(config_path / "usage.json")
|
||||||
|
identity._write_atomic({"anon_id": "test-ñ-123", "linked_principal_id": None})
|
||||||
|
|
||||||
|
raw = (config_path / "usage.json").read_bytes()
|
||||||
|
text = raw.decode("utf-8")
|
||||||
|
data = json.loads(text)
|
||||||
|
assert data["anon_id"] == "test-ñ-123"
|
||||||
|
|
||||||
|
def test_utils_load_dotenv_reads_utf8(self, tmp_path: Path) -> None:
|
||||||
|
from arcade_cli.utils import load_dotenv
|
||||||
|
|
||||||
|
env_file = tmp_path / ".env"
|
||||||
|
env_file.write_text("DB_PASSWORD=pässwörd\n", encoding="utf-8")
|
||||||
|
|
||||||
|
result = load_dotenv(env_file, override=False)
|
||||||
|
assert result.get("DB_PASSWORD") == "pässwörd"
|
||||||
|
|
||||||
|
def test_new_toolkit_files_are_utf8(self, tmp_path: Path) -> None:
|
||||||
|
from arcade_cli.new import create_new_toolkit_minimal
|
||||||
|
|
||||||
|
output_dir = tmp_path / "scaffolded"
|
||||||
|
output_dir.mkdir()
|
||||||
|
create_new_toolkit_minimal(str(output_dir), "my_server")
|
||||||
|
|
||||||
|
server_py = output_dir / "my_server" / "src" / "my_server" / "server.py"
|
||||||
|
assert server_py.exists()
|
||||||
|
content = server_py.read_bytes().decode("utf-8")
|
||||||
|
assert len(content) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileLockingErrorHandling:
|
||||||
|
"""Verify graceful handling for Windows-style file-locking scenarios."""
|
||||||
|
|
||||||
|
def test_logout_handles_permission_error(self) -> None:
|
||||||
|
with (
|
||||||
|
patch("arcade_cli.main.os.path.exists", return_value=True),
|
||||||
|
patch("arcade_cli.main.os.remove", side_effect=PermissionError("Locked")),
|
||||||
|
patch("arcade_cli.main.handle_cli_error") as mock_error,
|
||||||
|
):
|
||||||
|
from arcade_cli.main import logout
|
||||||
|
|
||||||
|
try:
|
||||||
|
logout(debug=False)
|
||||||
|
except SystemExit:
|
||||||
|
pass
|
||||||
|
|
||||||
|
mock_error.assert_called_once()
|
||||||
|
message = mock_error.call_args[0][0].lower()
|
||||||
|
assert "in use" in message or "lock" in message
|
||||||
|
|
||||||
|
def test_logout_permission_error_does_not_double_report(self) -> None:
|
||||||
|
from arcade_cli.utils import CLIError
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("arcade_cli.main.os.path.exists", return_value=True),
|
||||||
|
patch("arcade_cli.main.os.remove", side_effect=PermissionError("Locked")),
|
||||||
|
patch("arcade_cli.main.handle_cli_error", side_effect=CLIError("locked")) as mock_error,
|
||||||
|
):
|
||||||
|
from arcade_cli.main import logout
|
||||||
|
|
||||||
|
with pytest.raises(CLIError):
|
||||||
|
logout(debug=False)
|
||||||
|
|
||||||
|
mock_error.assert_called_once()
|
||||||
|
|
||||||
|
def test_remove_toolkit_handles_permission_error(self, tmp_path: Path) -> None:
|
||||||
|
from arcade_cli.new import remove_toolkit
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
toolkit_path = tmp_path / "locked_toolkit"
|
||||||
|
toolkit_path.mkdir()
|
||||||
|
(toolkit_path / "file.py").write_text("x", encoding="utf-8")
|
||||||
|
|
||||||
|
buf = StringIO()
|
||||||
|
test_console = Console(file=buf, force_terminal=False)
|
||||||
|
|
||||||
|
import arcade_cli.new as new_mod
|
||||||
|
|
||||||
|
original_console = new_mod.console
|
||||||
|
new_mod.console = test_console
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch("arcade_cli.new.shutil.rmtree", side_effect=PermissionError("Locked")):
|
||||||
|
remove_toolkit(tmp_path, "locked_toolkit")
|
||||||
|
finally:
|
||||||
|
new_mod.console = original_console
|
||||||
|
|
||||||
|
output = buf.getvalue()
|
||||||
|
assert "Warning" in output or "Could not" in output
|
||||||
|
|
@ -23,7 +23,7 @@ runner = CliRunner()
|
||||||
def test_dashboard_url_construction(args, expected_url):
|
def test_dashboard_url_construction(args, expected_url):
|
||||||
"""Test that the dashboard command constructs the correct URL with various args."""
|
"""Test that the dashboard command constructs the correct URL with various args."""
|
||||||
with (
|
with (
|
||||||
patch("webbrowser.open") as mock_open,
|
patch("arcade_cli.main._open_browser") as mock_open,
|
||||||
patch("arcade_cli.utils.validate_and_get_config") as mock_validate,
|
patch("arcade_cli.utils.validate_and_get_config") as mock_validate,
|
||||||
patch("arcade_cli.main.log_engine_health") as mock_health_check,
|
patch("arcade_cli.main.log_engine_health") as mock_health_check,
|
||||||
):
|
):
|
||||||
|
|
@ -41,9 +41,9 @@ def test_dashboard_url_construction(args, expected_url):
|
||||||
|
|
||||||
|
|
||||||
def test_fallback_when_browser_fails():
|
def test_fallback_when_browser_fails():
|
||||||
"""Test fallback message when browser.open fails."""
|
"""Test fallback message when _open_browser fails."""
|
||||||
with (
|
with (
|
||||||
patch("webbrowser.open") as mock_open,
|
patch("arcade_cli.main._open_browser") as mock_open,
|
||||||
patch("arcade_cli.utils.validate_and_get_config") as mock_validate,
|
patch("arcade_cli.utils.validate_and_get_config") as mock_validate,
|
||||||
patch("arcade_cli.main.log_engine_health") as mock_health_check,
|
patch("arcade_cli.main.log_engine_health") as mock_health_check,
|
||||||
patch("arcade_cli.main.console.print") as mock_print,
|
patch("arcade_cli.main.console.print") as mock_print,
|
||||||
|
|
@ -55,16 +55,20 @@ def test_fallback_when_browser_fails():
|
||||||
result = runner.invoke(cli, ["dashboard"])
|
result = runner.invoke(cli, ["dashboard"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
mock_print.assert_any_call(
|
# The fallback message should mention the URL and hint about manual paste.
|
||||||
f"If a browser doesn't open automatically, copy this URL and paste it into your browser: https://{PROD_ENGINE_HOST}/dashboard",
|
fallback_calls = [
|
||||||
style="dim",
|
call for call in mock_print.call_args_list
|
||||||
|
if "browser" in str(call).lower() and "dashboard" in str(call).lower()
|
||||||
|
]
|
||||||
|
assert len(fallback_calls) >= 1, (
|
||||||
|
f"Expected a fallback message about browser. Got calls: {mock_print.call_args_list}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_health_check_success():
|
def test_health_check_success():
|
||||||
"""Test successful health check."""
|
"""Test successful health check."""
|
||||||
with (
|
with (
|
||||||
patch("webbrowser.open") as mock_open,
|
patch("arcade_cli.main._open_browser") as mock_open,
|
||||||
patch("arcade_cli.utils.validate_and_get_config") as mock_validate,
|
patch("arcade_cli.utils.validate_and_get_config") as mock_validate,
|
||||||
patch("arcade_cli.main.log_engine_health") as mock_health_check,
|
patch("arcade_cli.main.log_engine_health") as mock_health_check,
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,13 @@ from unittest.mock import Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from arcade_cli.display import display_eval_results
|
from arcade_cli.display import display_eval_results
|
||||||
|
|
||||||
|
# test_display exercises eval-formatting paths that depend on optional extras.
|
||||||
|
# Skip cleanly when those extras are not installed in the active environment.
|
||||||
|
pytest.importorskip("openai")
|
||||||
|
pytest.importorskip("pytz")
|
||||||
|
pytest.importorskip("numpy")
|
||||||
|
pytest.importorskip("scipy")
|
||||||
from arcade_evals.eval import EvaluationResult
|
from arcade_evals.eval import EvaluationResult
|
||||||
|
|
||||||
# Mark all tests in this module as requiring evals dependencies
|
# Mark all tests in this module as requiring evals dependencies
|
||||||
|
|
@ -118,7 +125,7 @@ def test_display_eval_results_with_output_file() -> None:
|
||||||
assert output_file.exists()
|
assert output_file.exists()
|
||||||
|
|
||||||
# Verify file contains some expected content
|
# Verify file contains some expected content
|
||||||
content = output_file.read_text()
|
content = output_file.read_text(encoding="utf-8")
|
||||||
assert "Model:" in content or "gpt-4o" in content
|
assert "Model:" in content or "gpt-4o" in content
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -160,7 +167,7 @@ def test_display_eval_results_with_output_file_and_failed_only() -> None:
|
||||||
assert output_file.exists()
|
assert output_file.exists()
|
||||||
|
|
||||||
# Verify file contains disclaimer and summary
|
# Verify file contains disclaimer and summary
|
||||||
content = output_file.read_text()
|
content = output_file.read_text(encoding="utf-8")
|
||||||
assert "failed-only" in content.lower() or "failed evaluation" in content.lower()
|
assert "failed-only" in content.lower() or "failed evaluation" in content.lower()
|
||||||
assert "Total: 5" in content # Should show original total
|
assert "Total: 5" in content # Should show original total
|
||||||
|
|
||||||
|
|
@ -515,7 +522,7 @@ def test_display_eval_results_failed_only_with_warnings_in_summary() -> None:
|
||||||
output_formats=["txt"],
|
output_formats=["txt"],
|
||||||
)
|
)
|
||||||
|
|
||||||
content = output_file.read_text()
|
content = output_file.read_text(encoding="utf-8")
|
||||||
# Should show warnings in summary
|
# Should show warnings in summary
|
||||||
assert "Warnings: 1" in content or "Warnings" in content
|
assert "Warnings: 1" in content or "Warnings" in content
|
||||||
|
|
||||||
|
|
@ -562,7 +569,7 @@ def test_display_eval_results_with_details_and_output() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
assert output_file.exists()
|
assert output_file.exists()
|
||||||
content = output_file.read_text()
|
content = output_file.read_text(encoding="utf-8")
|
||||||
assert "User Input:" in content
|
assert "User Input:" in content
|
||||||
assert "Details:" in content
|
assert "Details:" in content
|
||||||
|
|
||||||
|
|
@ -603,11 +610,11 @@ def test_display_eval_results_multi_format_output() -> None:
|
||||||
assert (Path(tmpdir) / "results.html").exists()
|
assert (Path(tmpdir) / "results.html").exists()
|
||||||
|
|
||||||
# Verify each file has appropriate content
|
# Verify each file has appropriate content
|
||||||
txt_content = (Path(tmpdir) / "results.txt").read_text()
|
txt_content = (Path(tmpdir) / "results.txt").read_text(encoding="utf-8")
|
||||||
assert "Test Case" in txt_content
|
assert "Test Case" in txt_content
|
||||||
|
|
||||||
md_content = (Path(tmpdir) / "results.md").read_text()
|
md_content = (Path(tmpdir) / "results.md").read_text(encoding="utf-8")
|
||||||
assert "# " in md_content # Markdown header
|
assert "# " in md_content # Markdown header
|
||||||
|
|
||||||
html_content = (Path(tmpdir) / "results.html").read_text()
|
html_content = (Path(tmpdir) / "results.html").read_text(encoding="utf-8")
|
||||||
assert "<html" in html_content
|
assert "<html" in html_content
|
||||||
|
|
|
||||||
22
libs/tests/cli/test_eval_paths.py
Normal file
22
libs/tests/cli/test_eval_paths.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from arcade_cli.utils import get_eval_files
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_eval_files_with_spaces(tmp_path: Path) -> None:
|
||||||
|
eval_root = tmp_path / "eval dir with spaces"
|
||||||
|
eval_root.mkdir()
|
||||||
|
|
||||||
|
file_one = eval_root / "eval_one.py"
|
||||||
|
file_one.write_text("print('one')\n", encoding="utf-8")
|
||||||
|
|
||||||
|
nested = eval_root / "nested dir"
|
||||||
|
nested.mkdir()
|
||||||
|
file_two = nested / "eval_two.py"
|
||||||
|
file_two.write_text("print('two')\n", encoding="utf-8")
|
||||||
|
|
||||||
|
results = get_eval_files(str(eval_root))
|
||||||
|
resolved = {p.resolve() for p in results}
|
||||||
|
|
||||||
|
assert file_one.resolve() in resolved
|
||||||
|
assert file_two.resolve() in resolved
|
||||||
62
libs/tests/cli/test_new_cli.py
Normal file
62
libs/tests/cli/test_new_cli.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
from io import StringIO
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from arcade_cli.new import create_new_toolkit_minimal
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_new_toolkit_minimal_with_spaces(tmp_path: Path) -> None:
|
||||||
|
output_dir = tmp_path / "dir with spaces"
|
||||||
|
output_dir.mkdir()
|
||||||
|
|
||||||
|
create_new_toolkit_minimal(str(output_dir), "my_server")
|
||||||
|
|
||||||
|
server_root = output_dir / "my_server"
|
||||||
|
assert (server_root / "pyproject.toml").is_file()
|
||||||
|
assert (server_root / "src" / "my_server" / "server.py").is_file()
|
||||||
|
assert (server_root / "src" / "my_server" / ".env.example").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_new_toolkit_minimal_prints_next_steps(tmp_path: Path) -> None:
|
||||||
|
"""After scaffolding, the CLI should print 'Next steps' guidance."""
|
||||||
|
output_dir = tmp_path / "scaffold_test"
|
||||||
|
output_dir.mkdir()
|
||||||
|
|
||||||
|
# Capture console output by replacing the module-level console.
|
||||||
|
buf = StringIO()
|
||||||
|
test_console = Console(file=buf, force_terminal=False)
|
||||||
|
|
||||||
|
import arcade_cli.new as new_mod
|
||||||
|
|
||||||
|
orig = new_mod.console
|
||||||
|
new_mod.console = test_console
|
||||||
|
try:
|
||||||
|
create_new_toolkit_minimal(str(output_dir), "demo_srv")
|
||||||
|
finally:
|
||||||
|
new_mod.console = orig
|
||||||
|
|
||||||
|
output = buf.getvalue()
|
||||||
|
assert "Next steps:" in output, f"Expected 'Next steps:' in output:\n{output}"
|
||||||
|
assert "uv run server.py" in output, f"Expected 'uv run server.py' in output:\n{output}"
|
||||||
|
assert "demo_srv" in output, f"Expected toolkit name in output:\n{output}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_new_toolkit_minimal_rejects_duplicate(tmp_path: Path) -> None:
|
||||||
|
"""Creating a toolkit with a name that already exists should raise."""
|
||||||
|
output_dir = tmp_path / "dup_test"
|
||||||
|
output_dir.mkdir()
|
||||||
|
|
||||||
|
create_new_toolkit_minimal(str(output_dir), "my_srv")
|
||||||
|
|
||||||
|
with pytest.raises(FileExistsError, match="already exists"):
|
||||||
|
create_new_toolkit_minimal(str(output_dir), "my_srv")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_new_toolkit_minimal_rejects_invalid_name(tmp_path: Path) -> None:
|
||||||
|
"""Toolkit names with invalid characters should raise ValueError."""
|
||||||
|
output_dir = tmp_path / "invalid_test"
|
||||||
|
output_dir.mkdir()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="illegal characters"):
|
||||||
|
create_new_toolkit_minimal(str(output_dir), "My-Server!")
|
||||||
|
|
@ -62,7 +62,7 @@ KEY2=value2
|
||||||
# This is a comment
|
# This is a comment
|
||||||
KEY3=value3
|
KEY3=value3
|
||||||
"""
|
"""
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False, encoding="utf-8") as f:
|
||||||
f.write(env_content)
|
f.write(env_content)
|
||||||
f.flush()
|
f.flush()
|
||||||
|
|
||||||
|
|
@ -82,7 +82,7 @@ KEY2='single quoted'
|
||||||
KEY3="value with = sign"
|
KEY3="value with = sign"
|
||||||
KEY4="value with # comment inside"
|
KEY4="value with # comment inside"
|
||||||
"""
|
"""
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False, encoding="utf-8") as f:
|
||||||
f.write(env_content)
|
f.write(env_content)
|
||||||
f.flush()
|
f.flush()
|
||||||
|
|
||||||
|
|
@ -102,7 +102,7 @@ KEY1=value1 # inline comment
|
||||||
KEY2="quoted value" # comment after quote
|
KEY2="quoted value" # comment after quote
|
||||||
KEY3=value3# no space before comment
|
KEY3=value3# no space before comment
|
||||||
"""
|
"""
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False, encoding="utf-8") as f:
|
||||||
f.write(env_content)
|
f.write(env_content)
|
||||||
f.flush()
|
f.flush()
|
||||||
|
|
||||||
|
|
@ -126,7 +126,7 @@ KEY3=value3
|
||||||
invalid_line_without_equals
|
invalid_line_without_equals
|
||||||
KEY4=value4
|
KEY4=value4
|
||||||
"""
|
"""
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False, encoding="utf-8") as f:
|
||||||
f.write(env_content)
|
f.write(env_content)
|
||||||
f.flush()
|
f.flush()
|
||||||
|
|
||||||
|
|
|
||||||
102
libs/tests/cli/test_server_logs.py
Normal file
102
libs/tests/cli/test_server_logs.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
from io import StringIO
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from arcade_cli.server import _display_deployment_logs, _stream_deployment_logs
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_deployment_logs_preserves_square_bracket_content() -> None:
|
||||||
|
buf = StringIO()
|
||||||
|
test_console = Console(file=buf, force_terminal=False)
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_response.json.return_value = [
|
||||||
|
{"timestamp": "2026-01-15T15:30:00Z", "line": "[INFO] startup [ERROR] details"}
|
||||||
|
]
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
import arcade_cli.server as server_mod
|
||||||
|
|
||||||
|
original_console = server_mod.console
|
||||||
|
server_mod.console = test_console
|
||||||
|
try:
|
||||||
|
with (
|
||||||
|
patch("arcade_cli.server.httpx.Client") as mock_httpx_client,
|
||||||
|
patch("arcade_cli.server._format_timestamp_to_local", return_value="2026-01-15 10:30:00 EST"),
|
||||||
|
):
|
||||||
|
mock_httpx_client.return_value.__enter__.return_value = mock_client
|
||||||
|
_display_deployment_logs(
|
||||||
|
"http://localhost:8123/logs",
|
||||||
|
{},
|
||||||
|
datetime(2026, 1, 15, 15, 30, 0),
|
||||||
|
datetime(2026, 1, 15, 15, 35, 0),
|
||||||
|
debug=False,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
server_mod.console = original_console
|
||||||
|
|
||||||
|
output = buf.getvalue()
|
||||||
|
assert "[2026-01-15 10:30:00 EST]" in output
|
||||||
|
assert "[INFO]" in output
|
||||||
|
assert "[ERROR]" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_deployment_logs_preserves_square_bracket_content() -> None:
|
||||||
|
buf = StringIO()
|
||||||
|
test_console = Console(file=buf, force_terminal=False)
|
||||||
|
|
||||||
|
class FakeStreamResponse:
|
||||||
|
async def __aenter__(self) -> FakeStreamResponse:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb) -> None: # type: ignore[no-untyped-def]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def aiter_lines(self): # type: ignore[no-untyped-def]
|
||||||
|
yield 'data: {"Timestamp":"2026-01-15T15:30:00Z","Line":"[ERROR] stream details"}'
|
||||||
|
yield "[INFO] plain stream line"
|
||||||
|
|
||||||
|
class FakeAsyncClient:
|
||||||
|
async def __aenter__(self) -> FakeAsyncClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb) -> None: # type: ignore[no-untyped-def]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def stream(self, *args, **kwargs) -> FakeStreamResponse: # type: ignore[no-untyped-def]
|
||||||
|
return FakeStreamResponse()
|
||||||
|
|
||||||
|
import arcade_cli.server as server_mod
|
||||||
|
|
||||||
|
original_console = server_mod.console
|
||||||
|
server_mod.console = test_console
|
||||||
|
try:
|
||||||
|
with (
|
||||||
|
patch("arcade_cli.server.httpx.AsyncClient", return_value=FakeAsyncClient()),
|
||||||
|
patch("arcade_cli.server._format_timestamp_to_local", return_value="2026-01-15 10:30:00 EST"),
|
||||||
|
):
|
||||||
|
asyncio.run(
|
||||||
|
_stream_deployment_logs(
|
||||||
|
"http://localhost:8123/logs/stream",
|
||||||
|
{},
|
||||||
|
datetime(2026, 1, 15, 15, 30, 0),
|
||||||
|
datetime(2026, 1, 15, 15, 35, 0),
|
||||||
|
debug=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
server_mod.console = original_console
|
||||||
|
|
||||||
|
output = buf.getvalue()
|
||||||
|
assert "[2026-01-15 10:30:00 EST]" in output
|
||||||
|
assert "[ERROR]" in output
|
||||||
|
assert "[INFO] plain stream line" in output
|
||||||
218
libs/tests/cli/test_stdio_signal.py
Normal file
218
libs/tests/cli/test_stdio_signal.py
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
"""Tests for Windows signal handling in stdio transport.
|
||||||
|
|
||||||
|
Verifies that:
|
||||||
|
- The signal-handler support message is suppressed on Windows.
|
||||||
|
- No noisy "Failed to set up signal handler" warning is logged on Windows.
|
||||||
|
- A stdlib signal.signal(SIGINT) fallback is registered on Windows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable, Coroutine
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_handler_support_message_is_suppressed_on_windows() -> None:
|
||||||
|
"""On Windows, don't log a user-facing signal-support message."""
|
||||||
|
from arcade_mcp_server.transports.stdio import StdioTransport
|
||||||
|
|
||||||
|
transport = StdioTransport(name="test-stdio")
|
||||||
|
|
||||||
|
log_records: list[logging.LogRecord] = []
|
||||||
|
|
||||||
|
class RecordHandler(logging.Handler):
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
log_records.append(record)
|
||||||
|
|
||||||
|
logger = logging.getLogger("arcade.mcp.transports.stdio")
|
||||||
|
handler = RecordHandler()
|
||||||
|
logger.addHandler(handler)
|
||||||
|
original_level = logger.level
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(sys, "platform", "win32"):
|
||||||
|
# Simulate the NotImplementedError that Windows raises for
|
||||||
|
# loop.add_signal_handler.
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
original_add = loop.add_signal_handler
|
||||||
|
|
||||||
|
def raise_not_impl(*args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
loop.add_signal_handler = raise_not_impl # type: ignore[assignment]
|
||||||
|
|
||||||
|
# Also mock signal.signal so we don't actually install a handler
|
||||||
|
with patch("arcade_mcp_server.transports.stdio.signal.signal"):
|
||||||
|
try:
|
||||||
|
await transport.start()
|
||||||
|
finally:
|
||||||
|
loop.add_signal_handler = original_add # type: ignore[assignment]
|
||||||
|
await transport.stop()
|
||||||
|
|
||||||
|
messages = [r.getMessage() for r in log_records]
|
||||||
|
assert not any("Windows does not support asyncio signal handlers" in m for m in messages), (
|
||||||
|
"Windows signal support message should be suppressed."
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
logger.setLevel(original_level)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_handler_no_failed_setup_warning_on_windows() -> None:
|
||||||
|
"""On Windows, avoid warning noise when asyncio signal handlers are unavailable."""
|
||||||
|
from arcade_mcp_server.transports.stdio import StdioTransport
|
||||||
|
|
||||||
|
transport = StdioTransport(name="test-stdio-once")
|
||||||
|
|
||||||
|
log_records: list[logging.LogRecord] = []
|
||||||
|
|
||||||
|
class RecordHandler(logging.Handler):
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
log_records.append(record)
|
||||||
|
|
||||||
|
logger = logging.getLogger("arcade.mcp.transports.stdio")
|
||||||
|
handler = RecordHandler()
|
||||||
|
logger.addHandler(handler)
|
||||||
|
original_level = logger.level
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(sys, "platform", "win32"):
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
original_add = loop.add_signal_handler
|
||||||
|
|
||||||
|
def raise_not_impl(*args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
loop.add_signal_handler = raise_not_impl # type: ignore[assignment]
|
||||||
|
|
||||||
|
with patch("arcade_mcp_server.transports.stdio.signal.signal"):
|
||||||
|
try:
|
||||||
|
await transport.start()
|
||||||
|
finally:
|
||||||
|
loop.add_signal_handler = original_add # type: ignore[assignment]
|
||||||
|
await transport.stop()
|
||||||
|
|
||||||
|
failed_setup_warnings = [
|
||||||
|
r for r in log_records if "Failed to set up signal handler" in r.getMessage()
|
||||||
|
]
|
||||||
|
assert len(failed_setup_warnings) == 0, (
|
||||||
|
"Should not emit setup warnings for expected Windows asyncio limitations."
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
logger.setLevel(original_level)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_signal_signal_fallback_registered_on_windows() -> None:
|
||||||
|
"""On Windows, signal.signal(SIGINT, ...) should be called as a fallback."""
|
||||||
|
from arcade_mcp_server.transports.stdio import StdioTransport
|
||||||
|
|
||||||
|
transport = StdioTransport(name="test-stdio-fallback")
|
||||||
|
|
||||||
|
log_records: list[logging.LogRecord] = []
|
||||||
|
|
||||||
|
class RecordHandler(logging.Handler):
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
log_records.append(record)
|
||||||
|
|
||||||
|
logger = logging.getLogger("arcade.mcp.transports.stdio")
|
||||||
|
handler = RecordHandler()
|
||||||
|
logger.addHandler(handler)
|
||||||
|
original_level = logger.level
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(sys, "platform", "win32"):
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
original_add = loop.add_signal_handler
|
||||||
|
|
||||||
|
def raise_not_impl(*args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
loop.add_signal_handler = raise_not_impl # type: ignore[assignment]
|
||||||
|
|
||||||
|
with patch("arcade_mcp_server.transports.stdio.signal.signal") as mock_signal:
|
||||||
|
try:
|
||||||
|
await transport.start()
|
||||||
|
finally:
|
||||||
|
loop.add_signal_handler = original_add # type: ignore[assignment]
|
||||||
|
await transport.stop()
|
||||||
|
|
||||||
|
# signal.signal should have been called with SIGINT
|
||||||
|
import signal
|
||||||
|
sigint_calls = [
|
||||||
|
c for c in mock_signal.call_args_list
|
||||||
|
if c[0][0] == signal.SIGINT
|
||||||
|
]
|
||||||
|
assert len(sigint_calls) == 1, (
|
||||||
|
f"Expected signal.signal(SIGINT, ...) to be called once. "
|
||||||
|
f"All calls: {mock_signal.call_args_list}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
logger.setLevel(original_level)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only SIGINT fallback behavior")
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_windows_sigint_fallback_schedules_stop_on_transport_loop() -> None:
|
||||||
|
"""Windows SIGINT fallback should schedule stop() on the captured event loop."""
|
||||||
|
import signal
|
||||||
|
|
||||||
|
import arcade_mcp_server.transports.stdio as stdio_mod
|
||||||
|
from arcade_mcp_server.transports.stdio import StdioTransport
|
||||||
|
|
||||||
|
transport = StdioTransport(name="test-stdio-loop-schedule")
|
||||||
|
registered_handlers: dict[int, Callable[[int, object], None]] = {}
|
||||||
|
scheduled_callbacks: list[Callable[[], None]] = []
|
||||||
|
created_coroutines: list[Coroutine[Any, Any, None]] = []
|
||||||
|
|
||||||
|
def capture_signal(signum: int, handler: Callable[[int, object], None]) -> None:
|
||||||
|
registered_handlers[signum] = handler
|
||||||
|
|
||||||
|
def capture_call_soon_threadsafe(callback: Callable[[], None], *args: object) -> None:
|
||||||
|
assert not args
|
||||||
|
scheduled_callbacks.append(callback)
|
||||||
|
|
||||||
|
def capture_create_task(coro: Coroutine[Any, Any, None]) -> object:
|
||||||
|
created_coroutines.append(coro)
|
||||||
|
coro.close()
|
||||||
|
return object()
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
original_add = loop.add_signal_handler
|
||||||
|
|
||||||
|
def raise_not_impl(*args: object, **kwargs: object) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
loop.add_signal_handler = raise_not_impl # type: ignore[method-assign]
|
||||||
|
with (
|
||||||
|
patch.object(loop, "call_soon_threadsafe", side_effect=capture_call_soon_threadsafe),
|
||||||
|
patch.object(loop, "create_task", side_effect=capture_create_task),
|
||||||
|
patch.object(stdio_mod.signal, "signal", side_effect=capture_signal),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
await transport.start()
|
||||||
|
|
||||||
|
handler = registered_handlers[signal.SIGINT]
|
||||||
|
handler(signal.SIGINT, object())
|
||||||
|
|
||||||
|
assert len(scheduled_callbacks) == 1
|
||||||
|
scheduled_callback = scheduled_callbacks[0]
|
||||||
|
|
||||||
|
scheduled_callback()
|
||||||
|
assert len(created_coroutines) == 1
|
||||||
|
finally:
|
||||||
|
loop.add_signal_handler = original_add # type: ignore[method-assign]
|
||||||
|
await transport.stop()
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""Tests for multi-provider utils functions."""
|
"""Tests for multi-provider utils functions."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -365,7 +366,17 @@ class TestParseOutputPaths:
|
||||||
def test_path_with_directory(self) -> None:
|
def test_path_with_directory(self) -> None:
|
||||||
"""Test parsing path with directory."""
|
"""Test parsing path with directory."""
|
||||||
base, formats = parse_output_paths(["output/results.json"])
|
base, formats = parse_output_paths(["output/results.json"])
|
||||||
assert base == "output/results"
|
# Path separator is OS-dependent; use Path to build the expected value.
|
||||||
|
assert base == str(Path("output") / "results")
|
||||||
|
assert formats == ["json"]
|
||||||
|
|
||||||
|
def test_path_with_spaces(self, tmp_path: Path) -> None:
|
||||||
|
"""Test parsing path with spaces."""
|
||||||
|
output_dir = tmp_path / "dir with spaces"
|
||||||
|
output_dir.mkdir()
|
||||||
|
output_path = output_dir / "results.json"
|
||||||
|
base, formats = parse_output_paths([str(output_path)])
|
||||||
|
assert base == str(output_dir / "results")
|
||||||
assert formats == ["json"]
|
assert formats == ["json"]
|
||||||
|
|
||||||
def test_none_returns_empty(self) -> None:
|
def test_none_returns_empty(self) -> None:
|
||||||
|
|
|
||||||
296
libs/tests/cli/test_windows_subprocess.py
Normal file
296
libs/tests/cli/test_windows_subprocess.py
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
"""Tests for Windows-specific subprocess flags and signal handling.
|
||||||
|
|
||||||
|
Verifies that:
|
||||||
|
- Background subprocess calls set CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP on Windows.
|
||||||
|
- _graceful_terminate sends CTRL_BREAK_EVENT on Windows, falls back to terminate().
|
||||||
|
- MCPApp._run_with_reload shutdown uses CTRL_BREAK_EVENT on Windows.
|
||||||
|
- stdio transport registers a stdlib signal.signal fallback on Windows.
|
||||||
|
|
||||||
|
Tests that verify Windows-specific *logic* (flag construction, signal dispatch)
|
||||||
|
keep ``sys.platform`` mocking because Popen/process objects are also fully mocked.
|
||||||
|
Tests for the non-Windows path use ``pytest.mark.skipif`` instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Shared constants/helpers keep Windows behavior tests DRY and focused.
|
||||||
|
WIN_CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
|
||||||
|
WIN_CREATE_NEW_PROCESS_GROUP = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
|
||||||
|
WIN_CTRL_BREAK_EVENT = getattr(signal, "CTRL_BREAK_EVENT", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _running_process() -> MagicMock:
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.poll.return_value = None # Process is running
|
||||||
|
return proc
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _patch_win32_subprocess_flags() -> Iterator[None]:
|
||||||
|
with (
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(subprocess, "CREATE_NO_WINDOW", WIN_CREATE_NO_WINDOW, create=True),
|
||||||
|
patch.object(
|
||||||
|
subprocess,
|
||||||
|
"CREATE_NEW_PROCESS_GROUP",
|
||||||
|
WIN_CREATE_NEW_PROCESS_GROUP,
|
||||||
|
create=True,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _patch_win32_ctrl_break() -> Iterator[None]:
|
||||||
|
with (
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(signal, "CTRL_BREAK_EVENT", WIN_CTRL_BREAK_EVENT, create=True),
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# deploy.py — start_server_process()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeployCreateNoWindow:
|
||||||
|
"""Verify start_server_process sets CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP on Windows."""
|
||||||
|
|
||||||
|
@patch("arcade_cli.deploy.find_python_interpreter")
|
||||||
|
@patch("arcade_cli.deploy.subprocess.Popen")
|
||||||
|
def test_sets_flags_on_win32(
|
||||||
|
self, mock_popen: MagicMock, mock_python: MagicMock
|
||||||
|
) -> None:
|
||||||
|
mock_python.return_value = Path("python.exe")
|
||||||
|
mock_popen.return_value = _running_process()
|
||||||
|
|
||||||
|
# sys.platform mock: verifies flag-construction logic with fully-mocked Popen.
|
||||||
|
with _patch_win32_subprocess_flags():
|
||||||
|
from arcade_cli.deploy import start_server_process
|
||||||
|
start_server_process("server.py")
|
||||||
|
|
||||||
|
_, kwargs = mock_popen.call_args
|
||||||
|
flags = kwargs.get("creationflags", 0)
|
||||||
|
# Both flags must be present
|
||||||
|
assert flags & WIN_CREATE_NO_WINDOW, "CREATE_NO_WINDOW must be set"
|
||||||
|
assert flags & WIN_CREATE_NEW_PROCESS_GROUP, "CREATE_NEW_PROCESS_GROUP must be set"
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform == "win32", reason="Non-Windows path: creationflags must be 0")
|
||||||
|
@patch("arcade_cli.deploy.find_python_interpreter")
|
||||||
|
@patch("arcade_cli.deploy.subprocess.Popen")
|
||||||
|
def test_no_creationflags_on_non_windows(
|
||||||
|
self, mock_popen: MagicMock, mock_python: MagicMock
|
||||||
|
) -> None:
|
||||||
|
mock_python.return_value = Path("python3")
|
||||||
|
mock_popen.return_value = _running_process()
|
||||||
|
|
||||||
|
from arcade_cli.deploy import start_server_process
|
||||||
|
start_server_process("server.py")
|
||||||
|
|
||||||
|
_, kwargs = mock_popen.call_args
|
||||||
|
assert kwargs.get("creationflags") == 0
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("debug", "expects_devnull"),
|
||||||
|
[
|
||||||
|
(False, True),
|
||||||
|
(True, False),
|
||||||
|
],
|
||||||
|
ids=["non-debug-devnull", "debug-inherits-streams"],
|
||||||
|
)
|
||||||
|
@patch("arcade_cli.deploy.find_python_interpreter")
|
||||||
|
@patch("arcade_cli.deploy.subprocess.Popen")
|
||||||
|
def test_stream_configuration_by_debug_mode(
|
||||||
|
self,
|
||||||
|
mock_popen: MagicMock,
|
||||||
|
mock_python: MagicMock,
|
||||||
|
debug: bool,
|
||||||
|
expects_devnull: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Stream handling should switch between DEVNULL and inherited streams."""
|
||||||
|
mock_python.return_value = Path("python.exe")
|
||||||
|
mock_popen.return_value = _running_process()
|
||||||
|
|
||||||
|
# sys.platform mock: verifies stream-mode logic with fully-mocked Popen.
|
||||||
|
with _patch_win32_subprocess_flags():
|
||||||
|
from arcade_cli.deploy import start_server_process
|
||||||
|
start_server_process("server.py", debug=debug)
|
||||||
|
|
||||||
|
_, kwargs = mock_popen.call_args
|
||||||
|
if expects_devnull:
|
||||||
|
assert kwargs.get("stdout") == subprocess.DEVNULL
|
||||||
|
assert kwargs.get("stderr") == subprocess.DEVNULL
|
||||||
|
else:
|
||||||
|
assert kwargs.get("stdout") is None
|
||||||
|
assert kwargs.get("stderr") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# deploy.py — _graceful_terminate()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGracefulTerminate:
|
||||||
|
"""Verify _graceful_terminate uses CTRL_BREAK_EVENT on Windows."""
|
||||||
|
|
||||||
|
def test_sends_ctrl_break_on_win32(self) -> None:
|
||||||
|
"""On Windows, _graceful_terminate should send CTRL_BREAK_EVENT."""
|
||||||
|
from arcade_cli.deploy import _graceful_terminate
|
||||||
|
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
|
||||||
|
# sys.platform mock: verifies CTRL_BREAK_EVENT dispatch with mocked process.
|
||||||
|
with _patch_win32_ctrl_break():
|
||||||
|
_graceful_terminate(mock_proc)
|
||||||
|
|
||||||
|
# Should try send_signal with CTRL_BREAK_EVENT (not terminate)
|
||||||
|
mock_proc.send_signal.assert_called_once_with(WIN_CTRL_BREAK_EVENT)
|
||||||
|
mock_proc.terminate.assert_not_called()
|
||||||
|
|
||||||
|
def test_falls_back_to_terminate_on_win32_oserror(self) -> None:
|
||||||
|
"""If send_signal fails on Windows, fall back to terminate."""
|
||||||
|
from arcade_cli.deploy import _graceful_terminate
|
||||||
|
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
mock_proc.send_signal.side_effect = OSError("Process exited")
|
||||||
|
|
||||||
|
# sys.platform mock: exercises OSError fallback with mocked process.
|
||||||
|
with _patch_win32_ctrl_break():
|
||||||
|
_graceful_terminate(mock_proc)
|
||||||
|
|
||||||
|
mock_proc.terminate.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform == "win32", reason="Non-Windows terminate() path")
|
||||||
|
def test_calls_terminate_on_linux(self) -> None:
|
||||||
|
"""On Linux/macOS, _graceful_terminate should call terminate() directly."""
|
||||||
|
from arcade_cli.deploy import _graceful_terminate
|
||||||
|
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
|
||||||
|
_graceful_terminate(mock_proc)
|
||||||
|
|
||||||
|
mock_proc.terminate.assert_called_once()
|
||||||
|
mock_proc.send_signal.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# mcp_app.py — runtime behavior checks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMcpAppSubprocess:
|
||||||
|
"""Verify MCPApp._run_with_reload subprocess behavior at runtime."""
|
||||||
|
|
||||||
|
def test_shutdown_sends_ctrl_break_on_win32(self) -> None:
|
||||||
|
"""On Windows, _run_with_reload sends CTRL_BREAK_EVENT for graceful child shutdown."""
|
||||||
|
from arcade_mcp_server.mcp_app import MCPApp
|
||||||
|
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
mock_proc.wait.return_value = None
|
||||||
|
|
||||||
|
# sys.platform mock: exercises Windows graceful shutdown path with mocked Popen/signal.
|
||||||
|
with (
|
||||||
|
_patch_win32_subprocess_flags(),
|
||||||
|
patch.object(signal, "CTRL_BREAK_EVENT", WIN_CTRL_BREAK_EVENT, create=True),
|
||||||
|
patch.object(subprocess, "Popen", return_value=mock_proc),
|
||||||
|
patch("arcade_mcp_server.mcp_app.watch", side_effect=KeyboardInterrupt),
|
||||||
|
):
|
||||||
|
app = MCPApp()
|
||||||
|
app._run_with_reload("127.0.0.1", 8000)
|
||||||
|
|
||||||
|
mock_proc.send_signal.assert_called_once_with(WIN_CTRL_BREAK_EVENT)
|
||||||
|
mock_proc.terminate.assert_not_called()
|
||||||
|
|
||||||
|
def test_shutdown_falls_back_to_terminate_on_win32_oserror(self) -> None:
|
||||||
|
"""On Windows, shutdown falls back to terminate() if send_signal raises OSError."""
|
||||||
|
from arcade_mcp_server.mcp_app import MCPApp
|
||||||
|
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
mock_proc.send_signal.side_effect = OSError("process already exited")
|
||||||
|
mock_proc.wait.return_value = None
|
||||||
|
|
||||||
|
# sys.platform mock: exercises OSError fallback path with mocked Popen/signal.
|
||||||
|
with (
|
||||||
|
_patch_win32_subprocess_flags(),
|
||||||
|
patch.object(signal, "CTRL_BREAK_EVENT", WIN_CTRL_BREAK_EVENT, create=True),
|
||||||
|
patch.object(subprocess, "Popen", return_value=mock_proc),
|
||||||
|
patch("arcade_mcp_server.mcp_app.watch", side_effect=KeyboardInterrupt),
|
||||||
|
):
|
||||||
|
app = MCPApp()
|
||||||
|
app._run_with_reload("127.0.0.1", 8000)
|
||||||
|
|
||||||
|
mock_proc.terminate.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform == "win32", reason="Non-Windows terminate() path")
|
||||||
|
def test_shutdown_calls_terminate_on_non_windows(self) -> None:
|
||||||
|
"""On Linux/macOS, _run_with_reload uses terminate() for graceful child shutdown."""
|
||||||
|
from arcade_mcp_server.mcp_app import MCPApp
|
||||||
|
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
mock_proc.wait.return_value = None
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(subprocess, "Popen", return_value=mock_proc),
|
||||||
|
patch("arcade_mcp_server.mcp_app.watch", side_effect=KeyboardInterrupt),
|
||||||
|
):
|
||||||
|
app = MCPApp()
|
||||||
|
app._run_with_reload("127.0.0.1", 8000)
|
||||||
|
|
||||||
|
mock_proc.terminate.assert_called_once()
|
||||||
|
mock_proc.send_signal.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# stdio.py — signal handler fallback
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestStdioSignalFallback:
|
||||||
|
"""Verify stdio transport registers a stdlib signal.signal fallback on Windows."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_registers_stdlib_signal_handler_on_windows(self) -> None:
|
||||||
|
"""On Windows, StdioTransport.start() calls signal.signal(SIGINT, ...) as fallback."""
|
||||||
|
import arcade_mcp_server.transports.stdio as stdio_mod
|
||||||
|
from arcade_mcp_server.transports.stdio import StdioTransport
|
||||||
|
|
||||||
|
transport = StdioTransport(name="test-win32-sigint")
|
||||||
|
registered_signals: list[int] = []
|
||||||
|
|
||||||
|
def capture_signal(signum: int, handler: object) -> None:
|
||||||
|
registered_signals.append(signum)
|
||||||
|
|
||||||
|
# sys.platform mock: exercises NotImplementedError fallback path that
|
||||||
|
# only occurs on Windows when asyncio signal handlers are unavailable.
|
||||||
|
with patch.object(sys, "platform", "win32"):
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
original_add = loop.add_signal_handler
|
||||||
|
|
||||||
|
def raise_not_impl(*args: object, **kwargs: object) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
loop.add_signal_handler = raise_not_impl # type: ignore[assignment]
|
||||||
|
with patch.object(stdio_mod.signal, "signal", side_effect=capture_signal):
|
||||||
|
try:
|
||||||
|
await transport.start()
|
||||||
|
finally:
|
||||||
|
loop.add_signal_handler = original_add # type: ignore[assignment]
|
||||||
|
await transport.stop()
|
||||||
|
|
||||||
|
assert signal.SIGINT in registered_signals, (
|
||||||
|
"StdioTransport must register signal.signal(SIGINT, ...) on Windows "
|
||||||
|
"as asyncio fallback; registered signals: "
|
||||||
|
f"{registered_signals}"
|
||||||
|
)
|
||||||
|
|
@ -51,7 +51,7 @@ class TestLoadOrCreate:
|
||||||
"""Test that load_or_create loads existing usage.json file."""
|
"""Test that load_or_create loads existing usage.json file."""
|
||||||
existing_data = {"anon_id": str(uuid.uuid4()), "linked_principal_id": "user-123"}
|
existing_data = {"anon_id": str(uuid.uuid4()), "linked_principal_id": "user-123"}
|
||||||
usage_file = temp_config_path / "usage.json"
|
usage_file = temp_config_path / "usage.json"
|
||||||
usage_file.write_text(json.dumps(existing_data))
|
usage_file.write_text(json.dumps(existing_data), encoding="utf-8")
|
||||||
|
|
||||||
data = identity.load_or_create()
|
data = identity.load_or_create()
|
||||||
|
|
||||||
|
|
@ -71,7 +71,7 @@ class TestLoadOrCreate:
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that load_or_create creates new data if JSON is corrupted."""
|
"""Test that load_or_create creates new data if JSON is corrupted."""
|
||||||
usage_file = temp_config_path / "usage.json"
|
usage_file = temp_config_path / "usage.json"
|
||||||
usage_file.write_text("{ invalid json }")
|
usage_file.write_text("{ invalid json }", encoding="utf-8")
|
||||||
|
|
||||||
data = identity.load_or_create()
|
data = identity.load_or_create()
|
||||||
|
|
||||||
|
|
@ -83,7 +83,7 @@ class TestLoadOrCreate:
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that load_or_create creates new data if anon_id is missing."""
|
"""Test that load_or_create creates new data if anon_id is missing."""
|
||||||
usage_file = temp_config_path / "usage.json"
|
usage_file = temp_config_path / "usage.json"
|
||||||
usage_file.write_text(json.dumps({"some_other_key": "value"}))
|
usage_file.write_text(json.dumps({"some_other_key": "value"}), encoding="utf-8")
|
||||||
|
|
||||||
data = identity.load_or_create()
|
data = identity.load_or_create()
|
||||||
|
|
||||||
|
|
@ -95,7 +95,7 @@ class TestLoadOrCreate:
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that load_or_create creates new data if JSON is not a dict."""
|
"""Test that load_or_create creates new data if JSON is not a dict."""
|
||||||
usage_file = temp_config_path / "usage.json"
|
usage_file = temp_config_path / "usage.json"
|
||||||
usage_file.write_text(json.dumps(["not", "a", "dict"]))
|
usage_file.write_text(json.dumps(["not", "a", "dict"]), encoding="utf-8")
|
||||||
|
|
||||||
data = identity.load_or_create()
|
data = identity.load_or_create()
|
||||||
|
|
||||||
|
|
@ -147,7 +147,8 @@ class TestGetDistinctId:
|
||||||
"""Test that get_distinct_id returns persisted linked_principal_id."""
|
"""Test that get_distinct_id returns persisted linked_principal_id."""
|
||||||
usage_file = temp_config_path / "usage.json"
|
usage_file = temp_config_path / "usage.json"
|
||||||
usage_file.write_text(
|
usage_file.write_text(
|
||||||
json.dumps({"anon_id": str(uuid.uuid4()), "linked_principal_id": "persisted-user-123"})
|
json.dumps({"anon_id": str(uuid.uuid4()), "linked_principal_id": "persisted-user-123"}),
|
||||||
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
distinct_id = identity.get_distinct_id()
|
distinct_id = identity.get_distinct_id()
|
||||||
|
|
@ -195,7 +196,7 @@ class TestGetPrincipalId:
|
||||||
"""Test that get_principal_id returns principal_id from API."""
|
"""Test that get_principal_id returns principal_id from API."""
|
||||||
# Create credentials file
|
# Create credentials file
|
||||||
credentials_file = temp_config_path / "credentials.yaml"
|
credentials_file = temp_config_path / "credentials.yaml"
|
||||||
credentials_file.write_text(yaml.dump({"cloud": {"api": {"key": "test-api-key"}}}))
|
credentials_file.write_text(yaml.dump({"cloud": {"api": {"key": "test-api-key"}}}), encoding="utf-8")
|
||||||
|
|
||||||
# Mock API response
|
# Mock API response
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
|
|
@ -218,7 +219,7 @@ class TestGetPrincipalId:
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that get_principal_id returns None on API failure."""
|
"""Test that get_principal_id returns None on API failure."""
|
||||||
credentials_file = temp_config_path / "credentials.yaml"
|
credentials_file = temp_config_path / "credentials.yaml"
|
||||||
credentials_file.write_text(yaml.dump({"cloud": {"api": {"key": "test-api-key"}}}))
|
credentials_file.write_text(yaml.dump({"cloud": {"api": {"key": "test-api-key"}}}), encoding="utf-8")
|
||||||
|
|
||||||
mock_get.side_effect = Exception("Network error")
|
mock_get.side_effect = Exception("Network error")
|
||||||
|
|
||||||
|
|
@ -231,7 +232,7 @@ class TestGetPrincipalId:
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that get_principal_id returns None when API key is missing."""
|
"""Test that get_principal_id returns None when API key is missing."""
|
||||||
credentials_file = temp_config_path / "credentials.yaml"
|
credentials_file = temp_config_path / "credentials.yaml"
|
||||||
credentials_file.write_text(yaml.dump({"cloud": {}}))
|
credentials_file.write_text(yaml.dump({"cloud": {}}), encoding="utf-8")
|
||||||
|
|
||||||
principal_id = identity.get_principal_id()
|
principal_id = identity.get_principal_id()
|
||||||
|
|
||||||
|
|
@ -243,7 +244,7 @@ class TestGetPrincipalId:
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that get_principal_id returns None on non-200 status code."""
|
"""Test that get_principal_id returns None on non-200 status code."""
|
||||||
credentials_file = temp_config_path / "credentials.yaml"
|
credentials_file = temp_config_path / "credentials.yaml"
|
||||||
credentials_file.write_text(yaml.dump({"cloud": {"api": {"key": "test-api-key"}}}))
|
credentials_file.write_text(yaml.dump({"cloud": {"api": {"key": "test-api-key"}}}), encoding="utf-8")
|
||||||
|
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.status_code = 401
|
mock_response.status_code = 401
|
||||||
|
|
@ -260,7 +261,8 @@ class TestGetPrincipalId:
|
||||||
"""Test that get_principal_id returns account_id using OAuth access token."""
|
"""Test that get_principal_id returns account_id using OAuth access token."""
|
||||||
credentials_file = temp_config_path / "credentials.yaml"
|
credentials_file = temp_config_path / "credentials.yaml"
|
||||||
credentials_file.write_text(
|
credentials_file.write_text(
|
||||||
yaml.dump({"cloud": {"auth": {"access_token": "oauth-token", "refresh_token": "x"}}})
|
yaml.dump({"cloud": {"auth": {"access_token": "oauth-token", "refresh_token": "x"}}}),
|
||||||
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
|
|
@ -302,7 +304,8 @@ class TestShouldAlias:
|
||||||
|
|
||||||
usage_file = temp_config_path / "usage.json"
|
usage_file = temp_config_path / "usage.json"
|
||||||
usage_file.write_text(
|
usage_file.write_text(
|
||||||
json.dumps({"anon_id": str(uuid.uuid4()), "linked_principal_id": principal_id})
|
json.dumps({"anon_id": str(uuid.uuid4()), "linked_principal_id": principal_id}),
|
||||||
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
should_alias = identity.should_alias()
|
should_alias = identity.should_alias()
|
||||||
|
|
|
||||||
49
libs/tests/core/test_dependency_alignment.py
Normal file
49
libs/tests/core/test_dependency_alignment.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import toml
|
||||||
|
from packaging.requirements import Requirement
|
||||||
|
from packaging.version import Version
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_pyproject(path: Path) -> dict:
|
||||||
|
return toml.load(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_requirement(dependencies: list[str], package_name: str) -> Requirement:
|
||||||
|
for dep in dependencies:
|
||||||
|
req = Requirement(dep)
|
||||||
|
if req.name == package_name:
|
||||||
|
return req
|
||||||
|
raise AssertionError(f"Missing dependency for {package_name!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_dependency_includes_workspace_arcade_core_version() -> None:
|
||||||
|
root_pyproject = _load_pyproject(REPO_ROOT / "pyproject.toml")
|
||||||
|
core_pyproject = _load_pyproject(REPO_ROOT / "libs/arcade-core/pyproject.toml")
|
||||||
|
|
||||||
|
root_deps: list[str] = root_pyproject["project"]["dependencies"]
|
||||||
|
core_version = Version(core_pyproject["project"]["version"])
|
||||||
|
core_req = _get_requirement(root_deps, "arcade-core")
|
||||||
|
|
||||||
|
assert core_version in core_req.specifier, (
|
||||||
|
"Root dependency constraint for arcade-core must include the current "
|
||||||
|
f"workspace version {core_version}; got {core_req.specifier!s}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_dependency_includes_workspace_arcade_mcp_server_version() -> None:
|
||||||
|
root_pyproject = _load_pyproject(REPO_ROOT / "pyproject.toml")
|
||||||
|
server_pyproject = _load_pyproject(REPO_ROOT / "libs/arcade-mcp-server/pyproject.toml")
|
||||||
|
|
||||||
|
root_deps: list[str] = root_pyproject["project"]["dependencies"]
|
||||||
|
server_version = Version(server_pyproject["project"]["version"])
|
||||||
|
server_req = _get_requirement(root_deps, "arcade-mcp-server")
|
||||||
|
|
||||||
|
assert server_version in server_req.specifier, (
|
||||||
|
"Root dependency constraint for arcade-mcp-server must include the current "
|
||||||
|
f"workspace version {server_version}; got {server_req.specifier!s}"
|
||||||
|
)
|
||||||
126
libs/tests/core/test_subprocess_utils.py
Normal file
126
libs/tests/core/test_subprocess_utils.py
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from arcade_core.subprocess_utils import (
|
||||||
|
build_windows_hidden_startupinfo,
|
||||||
|
get_windows_no_window_creationflags,
|
||||||
|
graceful_terminate_process,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyStartupInfo:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.dwFlags = 0
|
||||||
|
self.wShowWindow = 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_creationflags_return_zero_on_non_windows() -> None:
|
||||||
|
with patch.object(sys, "platform", "linux"):
|
||||||
|
assert get_windows_no_window_creationflags() == 0
|
||||||
|
assert get_windows_no_window_creationflags(new_process_group=True) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_creationflags_windows_include_no_window_flag() -> None:
|
||||||
|
create_no_window = 0x08000000
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(subprocess, "CREATE_NO_WINDOW", create_no_window, create=True),
|
||||||
|
):
|
||||||
|
flags = get_windows_no_window_creationflags()
|
||||||
|
|
||||||
|
assert flags == create_no_window
|
||||||
|
|
||||||
|
|
||||||
|
def test_creationflags_windows_can_include_new_process_group() -> None:
|
||||||
|
create_no_window = 0x08000000
|
||||||
|
create_new_group = 0x00000200
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(subprocess, "CREATE_NO_WINDOW", create_no_window, create=True),
|
||||||
|
patch.object(subprocess, "CREATE_NEW_PROCESS_GROUP", create_new_group, create=True),
|
||||||
|
):
|
||||||
|
flags = get_windows_no_window_creationflags(new_process_group=True)
|
||||||
|
|
||||||
|
assert flags & create_no_window
|
||||||
|
assert flags & create_new_group
|
||||||
|
|
||||||
|
|
||||||
|
def test_hidden_startupinfo_returns_none_on_non_windows() -> None:
|
||||||
|
with patch.object(sys, "platform", "linux"):
|
||||||
|
assert build_windows_hidden_startupinfo() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_hidden_startupinfo_sets_sw_hide_on_windows() -> None:
|
||||||
|
startf_use_show_window = 0x00000001
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(subprocess, "STARTUPINFO", _DummyStartupInfo, create=True),
|
||||||
|
patch.object(subprocess, "STARTF_USESHOWWINDOW", startf_use_show_window, create=True),
|
||||||
|
):
|
||||||
|
startupinfo = build_windows_hidden_startupinfo()
|
||||||
|
|
||||||
|
assert startupinfo is not None
|
||||||
|
assert startupinfo.wShowWindow == 0
|
||||||
|
assert startupinfo.dwFlags & startf_use_show_window
|
||||||
|
|
||||||
|
|
||||||
|
def test_hidden_startupinfo_returns_none_if_startupinfo_missing() -> None:
|
||||||
|
with (
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(subprocess, "STARTUPINFO", None, create=True),
|
||||||
|
):
|
||||||
|
assert build_windows_hidden_startupinfo() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_graceful_terminate_uses_ctrl_break_on_windows() -> None:
|
||||||
|
ctrl_break_event = 1
|
||||||
|
process = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(signal, "CTRL_BREAK_EVENT", ctrl_break_event, create=True),
|
||||||
|
):
|
||||||
|
graceful_terminate_process(process)
|
||||||
|
|
||||||
|
process.send_signal.assert_called_once_with(ctrl_break_event)
|
||||||
|
process.terminate.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_graceful_terminate_falls_back_to_terminate_on_windows_signal_error() -> None:
|
||||||
|
ctrl_break_event = 1
|
||||||
|
process = MagicMock()
|
||||||
|
process.send_signal.side_effect = OSError("already exited")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(signal, "CTRL_BREAK_EVENT", ctrl_break_event, create=True),
|
||||||
|
):
|
||||||
|
graceful_terminate_process(process)
|
||||||
|
|
||||||
|
process.send_signal.assert_called_once_with(ctrl_break_event)
|
||||||
|
process.terminate.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_graceful_terminate_calls_terminate_on_non_windows() -> None:
|
||||||
|
process = MagicMock()
|
||||||
|
|
||||||
|
with patch.object(sys, "platform", "linux"):
|
||||||
|
graceful_terminate_process(process)
|
||||||
|
|
||||||
|
process.send_signal.assert_not_called()
|
||||||
|
process.terminate.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_graceful_terminate_swallows_terminate_oserror() -> None:
|
||||||
|
process = MagicMock()
|
||||||
|
process.terminate.side_effect = OSError("already exited")
|
||||||
|
|
||||||
|
with patch.object(sys, "platform", "linux"):
|
||||||
|
graceful_terminate_process(process)
|
||||||
|
|
@ -366,7 +366,7 @@ class TestValidateFile:
|
||||||
"""Test validation of Python files with valid syntax."""
|
"""Test validation of Python files with valid syntax."""
|
||||||
# Create a temporary valid Python file
|
# Create a temporary valid Python file
|
||||||
valid_file = Path("valid.py")
|
valid_file = Path("valid.py")
|
||||||
valid_file.write_text("def test(): return True")
|
valid_file.write_text("def test(): return True", encoding="utf-8")
|
||||||
|
|
||||||
# Should not raise any exceptions
|
# Should not raise any exceptions
|
||||||
Toolkit.validate_file(valid_file)
|
Toolkit.validate_file(valid_file)
|
||||||
|
|
@ -384,7 +384,7 @@ class TestValidateFile:
|
||||||
def test_validate_tools_non_python_file(self):
|
def test_validate_tools_non_python_file(self):
|
||||||
"""Test validation with non-Python file."""
|
"""Test validation with non-Python file."""
|
||||||
txt_file = Path("test.txt")
|
txt_file = Path("test.txt")
|
||||||
txt_file.write_text("Not a Python file")
|
txt_file.write_text("Not a Python file", encoding="utf-8")
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Not a Python file"):
|
with pytest.raises(ValueError, match="Not a Python file"):
|
||||||
Toolkit.validate_file(txt_file)
|
Toolkit.validate_file(txt_file)
|
||||||
|
|
@ -393,7 +393,7 @@ class TestValidateFile:
|
||||||
def test_validate_tools_syntax_error(self):
|
def test_validate_tools_syntax_error(self):
|
||||||
"""Test validation with Python file containing syntax errors."""
|
"""Test validation with Python file containing syntax errors."""
|
||||||
invalid_file = Path("invalid.py")
|
invalid_file = Path("invalid.py")
|
||||||
invalid_file.write_text("def test(): return True:") # Invalid syntax
|
invalid_file.write_text("def test(): return True:", encoding="utf-8") # Invalid syntax
|
||||||
|
|
||||||
with pytest.raises(SyntaxError):
|
with pytest.raises(SyntaxError):
|
||||||
Toolkit.validate_file(invalid_file)
|
Toolkit.validate_file(invalid_file)
|
||||||
|
|
@ -516,7 +516,7 @@ class TestToolsFromDirectory:
|
||||||
package_dir = tmp_path / "mypackage"
|
package_dir = tmp_path / "mypackage"
|
||||||
package_dir.mkdir()
|
package_dir.mkdir()
|
||||||
|
|
||||||
(package_dir / "__init__.py").write_text("")
|
(package_dir / "__init__.py").write_text("", encoding="utf-8")
|
||||||
(package_dir / "entrypoint.py").write_text(
|
(package_dir / "entrypoint.py").write_text(
|
||||||
'''
|
'''
|
||||||
from arcade_mcp_server import tool
|
from arcade_mcp_server import tool
|
||||||
|
|
@ -525,11 +525,12 @@ from arcade_mcp_server import tool
|
||||||
def my_tool():
|
def my_tool():
|
||||||
"""A tool."""
|
"""A tool."""
|
||||||
pass
|
pass
|
||||||
'''
|
''',
|
||||||
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
tools_dir = package_dir / "tools"
|
tools_dir = package_dir / "tools"
|
||||||
tools_dir.mkdir()
|
tools_dir.mkdir()
|
||||||
(tools_dir / "__init__.py").write_text("")
|
(tools_dir / "__init__.py").write_text("", encoding="utf-8")
|
||||||
(tools_dir / "helper.py").write_text(
|
(tools_dir / "helper.py").write_text(
|
||||||
'''
|
'''
|
||||||
from arcade_mcp_server import tool
|
from arcade_mcp_server import tool
|
||||||
|
|
@ -538,7 +539,8 @@ from arcade_mcp_server import tool
|
||||||
def helper_tool():
|
def helper_tool():
|
||||||
"""A helper tool."""
|
"""A helper tool."""
|
||||||
pass
|
pass
|
||||||
'''
|
''',
|
||||||
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
return package_dir
|
return package_dir
|
||||||
|
|
|
||||||
152
libs/tests/core/usage/test_usage_service.py
Normal file
152
libs/tests/core/usage/test_usage_service.py
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import PureWindowsPath
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from arcade_core.usage.usage_service import UsageService
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyStartupInfo:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.dwFlags = 0
|
||||||
|
self.wShowWindow = 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_windows_prefers_pythonw_and_hides_window() -> None:
|
||||||
|
service = UsageService()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("arcade_core.usage.usage_service.is_tracking_enabled", return_value=True),
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(sys, "executable", r"C:\Python\python.exe"),
|
||||||
|
patch.object(sys, "base_prefix", r"C:\Python", create=True),
|
||||||
|
patch("arcade_core.usage.usage_service.Path.exists", return_value=True),
|
||||||
|
patch("arcade_core.usage.usage_service.shutil.which", return_value=None),
|
||||||
|
patch.object(subprocess, "STARTUPINFO", _DummyStartupInfo, create=True),
|
||||||
|
patch.object(subprocess, "STARTF_USESHOWWINDOW", 0x00000001, create=True),
|
||||||
|
patch.object(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200, create=True),
|
||||||
|
patch.object(subprocess, "CREATE_NO_WINDOW", 0x08000000, create=True),
|
||||||
|
patch("arcade_core.usage.usage_service.subprocess.Popen") as mock_popen,
|
||||||
|
):
|
||||||
|
service.capture("event", "distinct-id", {"k": "v"})
|
||||||
|
|
||||||
|
args, kwargs = mock_popen.call_args
|
||||||
|
cmd = args[0]
|
||||||
|
|
||||||
|
assert cmd[0].lower().endswith("pythonw.exe")
|
||||||
|
assert cmd[1:] == ["-m", "arcade_core.usage"]
|
||||||
|
|
||||||
|
flags = kwargs["creationflags"]
|
||||||
|
assert flags & 0x00000200 # CREATE_NEW_PROCESS_GROUP
|
||||||
|
assert flags & 0x08000000 # CREATE_NO_WINDOW
|
||||||
|
assert not (flags & 0x00000008) # DETACHED_PROCESS should not be used
|
||||||
|
|
||||||
|
startupinfo = kwargs["startupinfo"]
|
||||||
|
assert startupinfo is not None
|
||||||
|
assert startupinfo.wShowWindow == 0
|
||||||
|
assert startupinfo.dwFlags & 0x00000001
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_windows_falls_back_to_python_when_pythonw_missing() -> None:
|
||||||
|
service = UsageService()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("arcade_core.usage.usage_service.is_tracking_enabled", return_value=True),
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(sys, "executable", r"C:\Python\python.exe"),
|
||||||
|
patch.object(sys, "base_prefix", r"C:\Python", create=True),
|
||||||
|
patch("arcade_core.usage.usage_service.Path.exists", return_value=False),
|
||||||
|
patch("arcade_core.usage.usage_service.shutil.which", return_value=None),
|
||||||
|
patch.object(subprocess, "STARTUPINFO", _DummyStartupInfo, create=True),
|
||||||
|
patch.object(subprocess, "STARTF_USESHOWWINDOW", 0x00000001, create=True),
|
||||||
|
patch.object(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200, create=True),
|
||||||
|
patch.object(subprocess, "CREATE_NO_WINDOW", 0x08000000, create=True),
|
||||||
|
patch("arcade_core.usage.usage_service.subprocess.Popen") as mock_popen,
|
||||||
|
):
|
||||||
|
service.capture("event", "distinct-id", {"k": "v"})
|
||||||
|
|
||||||
|
args, _kwargs = mock_popen.call_args
|
||||||
|
cmd = args[0]
|
||||||
|
assert cmd[0] == r"C:\Python\python.exe"
|
||||||
|
assert cmd[1:] == ["-m", "arcade_core.usage"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_non_windows_uses_start_new_session() -> None:
|
||||||
|
service = UsageService()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("arcade_core.usage.usage_service.is_tracking_enabled", return_value=True),
|
||||||
|
patch.object(sys, "platform", "linux"),
|
||||||
|
patch("arcade_core.usage.usage_service.shutil.which", return_value=None),
|
||||||
|
patch("arcade_core.usage.usage_service.subprocess.Popen") as mock_popen,
|
||||||
|
):
|
||||||
|
service.capture("event", "distinct-id", {"k": "v"})
|
||||||
|
|
||||||
|
_, kwargs = mock_popen.call_args
|
||||||
|
assert kwargs["start_new_session"] is True
|
||||||
|
assert "creationflags" not in kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_noop_when_tracking_disabled() -> None:
|
||||||
|
service = UsageService()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("arcade_core.usage.usage_service.is_tracking_enabled", return_value=False),
|
||||||
|
patch("arcade_core.usage.usage_service.shutil.which", return_value=None),
|
||||||
|
patch("arcade_core.usage.usage_service.subprocess.Popen") as mock_popen,
|
||||||
|
):
|
||||||
|
service.capture("event", "distinct-id", {"k": "v"})
|
||||||
|
|
||||||
|
mock_popen.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_windows_uses_base_prefix_pythonw_when_venv_pythonw_missing() -> None:
|
||||||
|
service = UsageService()
|
||||||
|
|
||||||
|
base_pythonw = r"C:\BasePython\pythonw.exe"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("arcade_core.usage.usage_service.is_tracking_enabled", return_value=True),
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(sys, "executable", r"C:\Venv\Scripts\python.exe"),
|
||||||
|
patch.object(sys, "base_prefix", r"C:\BasePython", create=True),
|
||||||
|
patch("arcade_core.usage.usage_service.Path.exists", side_effect=[False, True]),
|
||||||
|
patch("arcade_core.usage.usage_service.shutil.which", return_value=None),
|
||||||
|
patch.object(subprocess, "STARTUPINFO", _DummyStartupInfo, create=True),
|
||||||
|
patch.object(subprocess, "STARTF_USESHOWWINDOW", 0x00000001, create=True),
|
||||||
|
patch.object(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200, create=True),
|
||||||
|
patch.object(subprocess, "CREATE_NO_WINDOW", 0x08000000, create=True),
|
||||||
|
patch("arcade_core.usage.usage_service.subprocess.Popen") as mock_popen,
|
||||||
|
):
|
||||||
|
service.capture("event", "distinct-id", {"k": "v"})
|
||||||
|
|
||||||
|
args, _kwargs = mock_popen.call_args
|
||||||
|
cmd = args[0]
|
||||||
|
assert str(PureWindowsPath(cmd[0])) == base_pythonw
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_windows_uses_pythonw_from_path_when_available() -> None:
|
||||||
|
service = UsageService()
|
||||||
|
|
||||||
|
path_pythonw = r"C:\Tools\pythonw.exe"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("arcade_core.usage.usage_service.is_tracking_enabled", return_value=True),
|
||||||
|
patch.object(sys, "platform", "win32"),
|
||||||
|
patch.object(sys, "executable", r"C:\Venv\Scripts\python.exe"),
|
||||||
|
patch.object(sys, "base_prefix", r"C:\BasePython", create=True),
|
||||||
|
patch("arcade_core.usage.usage_service.Path.exists", side_effect=[False, False, True]),
|
||||||
|
patch("arcade_core.usage.usage_service.shutil.which", return_value=path_pythonw),
|
||||||
|
patch.object(subprocess, "STARTUPINFO", _DummyStartupInfo, create=True),
|
||||||
|
patch.object(subprocess, "STARTF_USESHOWWINDOW", 0x00000001, create=True),
|
||||||
|
patch.object(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200, create=True),
|
||||||
|
patch.object(subprocess, "CREATE_NO_WINDOW", 0x08000000, create=True),
|
||||||
|
patch("arcade_core.usage.usage_service.subprocess.Popen") as mock_popen,
|
||||||
|
):
|
||||||
|
service.capture("event", "distinct-id", {"k": "v"})
|
||||||
|
|
||||||
|
args, _kwargs = mock_popen.call_args
|
||||||
|
cmd = args[0]
|
||||||
|
assert cmd[0] == path_pythonw
|
||||||
|
|
@ -399,7 +399,7 @@ class TestCaptureResult:
|
||||||
|
|
||||||
# Verify file was created and has valid content
|
# Verify file was created and has valid content
|
||||||
assert filepath.exists()
|
assert filepath.exists()
|
||||||
with open(filepath) as f:
|
with open(filepath, encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
assert data["suite_name"] == "Suite"
|
assert data["suite_name"] == "Suite"
|
||||||
assert len(data["captured_cases"]) == 1
|
assert len(data["captured_cases"]) == 1
|
||||||
|
|
@ -425,7 +425,7 @@ class TestCaptureResult:
|
||||||
filepath = Path(tmpdir) / "capture_output.json"
|
filepath = Path(tmpdir) / "capture_output.json"
|
||||||
result.write_to_file(str(filepath), include_context=True)
|
result.write_to_file(str(filepath), include_context=True)
|
||||||
|
|
||||||
with open(filepath) as f:
|
with open(filepath, encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
assert data["captured_cases"][0]["system_message"] == "System"
|
assert data["captured_cases"][0]["system_message"] == "System"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ from arcade_evals.eval import (
|
||||||
_resolve_pass_rule,
|
_resolve_pass_rule,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# _compute_mean_std tests
|
# _compute_mean_std tests
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "arcade-mcp"
|
name = "arcade-mcp"
|
||||||
version = "1.11.0"
|
version = "1.11.1"
|
||||||
description = "Arcade.dev - Tool Calling platform for Agents"
|
description = "Arcade.dev - Tool Calling platform for Agents"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
|
|
@ -19,8 +19,8 @@ requires-python = ">=3.10"
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
# CLI dependencies
|
# CLI dependencies
|
||||||
"arcade-mcp-server>=1.17.0,<2.0.0",
|
"arcade-mcp-server>=1.17.2,<2.0.0",
|
||||||
"arcade-core>=4.4.0,<5.0.0",
|
"arcade-core>=4.4.2,<5.0.0",
|
||||||
"typer==0.10.0",
|
"typer==0.10.0",
|
||||||
"rich>=14.0.0,<15.0.0",
|
"rich>=14.0.0,<15.0.0",
|
||||||
"Jinja2==3.1.6",
|
"Jinja2==3.1.6",
|
||||||
|
|
@ -29,6 +29,7 @@ dependencies = [
|
||||||
"tqdm==4.67.1",
|
"tqdm==4.67.1",
|
||||||
"click==8.1.8",
|
"click==8.1.8",
|
||||||
"python-dateutil>=2.8.2",
|
"python-dateutil>=2.8.2",
|
||||||
|
"platformdirs>=4.3.6; platform_system == 'Windows'",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|
@ -56,6 +57,10 @@ evals = [
|
||||||
"pytz>=2024.1",
|
"pytz>=2024.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
windows = [
|
||||||
|
"platformdirs>=4.3.6",
|
||||||
|
]
|
||||||
|
|
||||||
dev = [
|
dev = [
|
||||||
# Test framework
|
# Test framework
|
||||||
"pytest>=8.1.2",
|
"pytest>=8.1.2",
|
||||||
|
|
@ -150,6 +155,9 @@ line-length = 100
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "N", "UP", "RUF"]
|
select = ["E", "F", "I", "N", "UP", "RUF"]
|
||||||
|
extend-select = ["PLW1514"]
|
||||||
|
preview = true
|
||||||
|
explicit-preview-rules = true
|
||||||
ignore = ["E501", "S105"]
|
ignore = ["E501", "S105"]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ The installation test (`test_install.py`) verifies:
|
||||||
2. **Installation**: Installs `arcade-mcp` from source using `uv`
|
2. **Installation**: Installs `arcade-mcp` from source using `uv`
|
||||||
3. **CLI Functionality**: Tests that the `arcade` CLI command is available and working
|
3. **CLI Functionality**: Tests that the `arcade` CLI command is available and working
|
||||||
4. **File Locking**: Verifies cross-platform file locking with `portalocker` (replacing `fcntl`)
|
4. **File Locking**: Verifies cross-platform file locking with `portalocker` (replacing `fcntl`)
|
||||||
|
5. **CLI Configure**: Writes temp config files for Cursor, VS Code, and Claude
|
||||||
|
6. **CLI New Scaffold**: Creates a minimal server in a path with spaces
|
||||||
|
|
||||||
## Running Locally
|
## Running Locally
|
||||||
|
|
||||||
|
|
@ -52,7 +54,7 @@ python3 tests/install/test_install.py
|
||||||
- Verifies `uv` is installed and available
|
- Verifies `uv` is installed and available
|
||||||
|
|
||||||
2. **Installs Package**
|
2. **Installs Package**
|
||||||
- Syncs dependencies with `uv sync --dev`
|
- Syncs dependencies with `uv sync --extra dev`
|
||||||
- Installs `arcade-mcp` in editable mode from source
|
- Installs `arcade-mcp` in editable mode from source
|
||||||
|
|
||||||
3. **Tests CLI**
|
3. **Tests CLI**
|
||||||
|
|
@ -60,7 +62,15 @@ python3 tests/install/test_install.py
|
||||||
- Tests `arcade --version` (optional)
|
- Tests `arcade --version` (optional)
|
||||||
- Tests `arcade whoami` (may fail if not logged in, but shouldn't crash)
|
- Tests `arcade whoami` (may fail if not logged in, but shouldn't crash)
|
||||||
|
|
||||||
4. **Tests File Locking**
|
4. **Tests CLI Configure**
|
||||||
|
- Runs `arcade configure` for Cursor, VS Code, and Claude using `--config`
|
||||||
|
- Verifies JSON structure for stdio/http configurations
|
||||||
|
|
||||||
|
5. **Tests CLI New Scaffold**
|
||||||
|
- Runs `arcade new` in a temp path with spaces
|
||||||
|
- Verifies expected files and directories are created
|
||||||
|
|
||||||
|
6. **Tests File Locking**
|
||||||
- Creates a temporary identity file
|
- Creates a temporary identity file
|
||||||
- Tests shared lock for reading
|
- Tests shared lock for reading
|
||||||
- Tests exclusive lock for writing
|
- Tests exclusive lock for writing
|
||||||
|
|
@ -95,6 +105,20 @@ CLI Functionality Tests
|
||||||
✅ Success: Check arcade version
|
✅ Success: Check arcade version
|
||||||
✅ Success: Test whoami command (no auth required)
|
✅ Success: Test whoami command (no auth required)
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
CLI Configure Tests
|
||||||
|
============================================================
|
||||||
|
✅ Success: Configure Cursor (stdio) with temp config
|
||||||
|
✅ Success: Configure Cursor (http) with temp config
|
||||||
|
✅ Success: Configure VS Code (stdio) with temp config
|
||||||
|
✅ Success: Configure VS Code (http) with temp config
|
||||||
|
✅ Success: Configure Claude (stdio) with temp config
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
CLI New Scaffold Tests
|
||||||
|
============================================================
|
||||||
|
✅ Success: Scaffold new server in path with spaces
|
||||||
|
|
||||||
============================================================
|
============================================================
|
||||||
File Locking Tests (portalocker)
|
File Locking Tests (portalocker)
|
||||||
============================================================
|
============================================================
|
||||||
|
|
@ -142,6 +166,16 @@ Or using pip:
|
||||||
pip install uv
|
pip install uv
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Windows: `uv` installed but still not found
|
||||||
|
|
||||||
|
On some Windows shells, `uv` is installed to `C:\Users\<user>\.local\bin` but
|
||||||
|
the current session PATH is not refreshed automatically. In PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:Path = "$env:USERPROFILE\.local\bin;$env:Path"
|
||||||
|
uv --version
|
||||||
|
```
|
||||||
|
|
||||||
### Permission Denied
|
### Permission Denied
|
||||||
|
|
||||||
If you get a permission error when running the script directly:
|
If you get a permission error when running the script directly:
|
||||||
|
|
@ -155,10 +189,21 @@ chmod +x tests/install/test_install.py
|
||||||
If you see import errors, make sure you're running from the project root and that dependencies are installed:
|
If you see import errors, make sure you're running from the project root and that dependencies are installed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync --dev
|
uv sync --extra dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional eval/display test dependencies
|
||||||
|
|
||||||
|
Some CLI test files (for eval/display formatting paths) require optional eval
|
||||||
|
dependencies such as `openai`, `pytz`, and `numpy`. Install the eval extras
|
||||||
|
before running those suites:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync --extra dev --extra evals
|
||||||
```
|
```
|
||||||
|
|
||||||
## Related Files
|
## Related Files
|
||||||
|
|
||||||
- `.github/workflows/test-install.yml` - GitHub Actions workflow
|
- `.github/workflows/test-install.yml` - GitHub Actions workflow
|
||||||
- `libs/arcade-core/arcade_core/usage/identity.py` - File locking implementation using `portalocker`
|
- `libs/arcade-core/arcade_core/usage/identity.py` - File locking implementation using `portalocker`
|
||||||
|
- `reports/windows-manual-test-checklist.md` - Windows manual validation steps
|
||||||
|
|
|
||||||
29
tests/install/conftest.py
Normal file
29
tests/install/conftest.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""Test configuration for installation tests.
|
||||||
|
|
||||||
|
Mirrors the autouse fixtures in libs/tests/conftest.py so that
|
||||||
|
`pytest tests/install/` benefits from the same env setup as the
|
||||||
|
unit/integration test suites.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def disable_usage_tracking() -> None:
|
||||||
|
"""Disable CLI usage tracking for all installation tests.
|
||||||
|
|
||||||
|
Prevents test runs from sending analytics events to PostHog.
|
||||||
|
Mirrors the same fixture in libs/tests/conftest.py.
|
||||||
|
"""
|
||||||
|
original_value = os.environ.get("ARCADE_USAGE_TRACKING")
|
||||||
|
|
||||||
|
os.environ["ARCADE_USAGE_TRACKING"] = "0"
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
if original_value is None:
|
||||||
|
os.environ.pop("ARCADE_USAGE_TRACKING", None)
|
||||||
|
else:
|
||||||
|
os.environ["ARCADE_USAGE_TRACKING"] = original_value
|
||||||
|
|
@ -35,13 +35,23 @@ class TestRunner:
|
||||||
self.test_results: list[tuple[str, bool]] = []
|
self.test_results: list[tuple[str, bool]] = []
|
||||||
|
|
||||||
def _find_arcade_command(self) -> list[str]:
|
def _find_arcade_command(self) -> list[str]:
|
||||||
"""Find the arcade command (either direct or via uv run)."""
|
"""Find the arcade command (either direct or via uv run).
|
||||||
|
|
||||||
|
When using uv run from temp dirs (e.g. configure tests), use
|
||||||
|
``--project`` instead of ``--directory`` so uv resolves the project
|
||||||
|
environment without changing the subprocess working directory.
|
||||||
|
"""
|
||||||
if shutil.which("arcade"):
|
if shutil.which("arcade"):
|
||||||
return ["arcade"]
|
return ["arcade"]
|
||||||
return ["uv", "run", "arcade"]
|
return ["uv", "run", "--project", str(self.project_root), "arcade"]
|
||||||
|
|
||||||
def run_command(
|
def run_command(
|
||||||
self, cmd: list[str], description: str, required: bool = True
|
self,
|
||||||
|
cmd: list[str],
|
||||||
|
description: str,
|
||||||
|
required: bool = True,
|
||||||
|
cwd: Path | None = None,
|
||||||
|
input_text: str | None = None,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
"""Run a command and return success status and output."""
|
"""Run a command and return success status and output."""
|
||||||
print(f"\n{'=' * 60}")
|
print(f"\n{'=' * 60}")
|
||||||
|
|
@ -61,6 +71,8 @@ class TestRunner:
|
||||||
check=True,
|
check=True,
|
||||||
timeout=60,
|
timeout=60,
|
||||||
env=env,
|
env=env,
|
||||||
|
cwd=str(cwd) if cwd else None,
|
||||||
|
input=input_text,
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
errors="replace",
|
errors="replace",
|
||||||
)
|
)
|
||||||
|
|
@ -103,6 +115,26 @@ class TestRunner:
|
||||||
success, _ = self.run_command(["uv", "--version"], "Check uv availability", required=True)
|
success, _ = self.run_command(["uv", "--version"], "Check uv availability", required=True)
|
||||||
return success
|
return success
|
||||||
|
|
||||||
|
def _sync_dependencies_command(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
current_test: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build the `uv sync` command for the current runtime context.
|
||||||
|
|
||||||
|
When this suite runs under pytest, avoid reinstalling pytest itself.
|
||||||
|
This prevents mutating the active test runner environment and also
|
||||||
|
avoids `pytest.exe` file-lock failures on Windows.
|
||||||
|
"""
|
||||||
|
runtime_test = (
|
||||||
|
current_test if current_test is not None else os.environ.get("PYTEST_CURRENT_TEST")
|
||||||
|
)
|
||||||
|
|
||||||
|
command = ["uv", "sync", "--dev"]
|
||||||
|
if runtime_test:
|
||||||
|
command.extend(["--inexact", "--no-install-package", "pytest"])
|
||||||
|
return command
|
||||||
|
|
||||||
def install_package(self) -> bool:
|
def install_package(self) -> bool:
|
||||||
"""Install arcade-mcp from source."""
|
"""Install arcade-mcp from source."""
|
||||||
print("\n" + "=" * 60)
|
print("\n" + "=" * 60)
|
||||||
|
|
@ -111,7 +143,7 @@ class TestRunner:
|
||||||
|
|
||||||
# Sync dependencies
|
# Sync dependencies
|
||||||
sync_success, _ = self.run_command(
|
sync_success, _ = self.run_command(
|
||||||
["uv", "sync", "--dev"],
|
self._sync_dependencies_command(),
|
||||||
"Sync dependencies with uv",
|
"Sync dependencies with uv",
|
||||||
required=True,
|
required=True,
|
||||||
)
|
)
|
||||||
|
|
@ -265,5 +297,32 @@ def main() -> int:
|
||||||
return runner.run_all_tests()
|
return runner.run_all_tests()
|
||||||
|
|
||||||
|
|
||||||
|
def test_installation() -> None:
|
||||||
|
"""Pytest entry point for the installation test suite.
|
||||||
|
|
||||||
|
Delegates to the existing TestRunner so the full install validation
|
||||||
|
runs under pytest (picks up conftest.py fixtures such as
|
||||||
|
disable_usage_tracking) without changing the internal test logic.
|
||||||
|
"""
|
||||||
|
exit_code = main()
|
||||||
|
assert exit_code == 0, "One or more installation tests failed — see output above."
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_dependencies_command_default(tmp_path: Path) -> None:
|
||||||
|
"""Use the baseline sync command outside Windows+pytest context."""
|
||||||
|
runner = TestRunner(tmp_path)
|
||||||
|
|
||||||
|
assert runner._sync_dependencies_command(current_test="") == ["uv", "sync", "--dev"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_dependencies_command_pytest_context(tmp_path: Path) -> None:
|
||||||
|
"""Avoid reinstalling pytest when this suite itself is running."""
|
||||||
|
runner = TestRunner(tmp_path)
|
||||||
|
|
||||||
|
assert runner._sync_dependencies_command(
|
||||||
|
current_test="tests/install/test_install.py::test_installation (call)",
|
||||||
|
) == ["uv", "sync", "--dev", "--inexact", "--no-install-package", "pytest"]
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|
|
||||||
694
tests/integration/mcp_protocol_smoke.py
Normal file
694
tests/integration/mcp_protocol_smoke.py
Normal file
|
|
@ -0,0 +1,694 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""MCP protocol smoke test for generated Arcade servers.
|
||||||
|
|
||||||
|
Why this exists alongside ``libs/tests/arcade_mcp_server/integration/test_end_to_end.py``
|
||||||
|
------------------------------------------------------------------------------------------
|
||||||
|
The existing ``test_end_to_end.py`` is a pytest suite that validates the arcade-mcp-server
|
||||||
|
*library* against a dedicated test-fixture server with rich coverage (logging, progress
|
||||||
|
notifications, tool chaining, sampling, elicitation, concurrency).
|
||||||
|
|
||||||
|
This smoke test serves a **different purpose**:
|
||||||
|
|
||||||
|
1. **Tests ``arcade new`` scaffolded output** — validates that a *generated* project's
|
||||||
|
``server.py`` works end-to-end, catching template regressions that library tests
|
||||||
|
cannot detect.
|
||||||
|
2. **Cross-platform CI entry point** — invoked by
|
||||||
|
``tests/integration/no_auth_cli_smoke.py`` across the OS matrix. It includes
|
||||||
|
platform-aware process management (e.g., ``taskkill /T /F`` on Windows and
|
||||||
|
non-``select()`` stderr draining for Windows pipes) that the pytest suite
|
||||||
|
does not exercise.
|
||||||
|
3. **Stdlib-only / zero external deps** — runs with nothing beyond a ``uv run python``
|
||||||
|
invocation so it works on fresh CI images before any ``pip install``.
|
||||||
|
4. **Standalone CLI** — uses ``argparse`` so PowerShell/bash scripts can invoke it
|
||||||
|
directly without a pytest harness.
|
||||||
|
|
||||||
|
The basic protocol flow (initialize → initialized → ping → tools/list → tools/call) is
|
||||||
|
intentionally re-implemented here rather than imported, because the stdlib-only constraint
|
||||||
|
and the need to run outside the project's virtualenv make shared helpers impractical.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
uv run python tests/integration/mcp_protocol_smoke.py \\
|
||||||
|
--server-dir "<path-to>/src/my_server" \\
|
||||||
|
--transport both
|
||||||
|
|
||||||
|
Tool naming convention (for reference)::
|
||||||
|
|
||||||
|
MCPApp(name="my_server") + def greet() -> MCP tool name "MyServer_Greet"
|
||||||
|
Toolkit name is snake_to_pascal_case(app_name), tool is snake_to_pascal_case(func_name).
|
||||||
|
MCP exposes the fully-qualified name with "." replaced by "_".
|
||||||
|
We find the greet tool by case-insensitive substring match on "greet".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import queue
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JSON-RPC helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_request(
|
||||||
|
method: str,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
req_id: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
msg: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
||||||
|
if params is not None:
|
||||||
|
msg["params"] = params
|
||||||
|
if req_id is not None:
|
||||||
|
msg["id"] = req_id
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_line(line: str) -> dict[str, Any] | None:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = json.loads(line)
|
||||||
|
return result if isinstance(result, dict) else None
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_ok(msg: dict[str, Any], expected_id: int, step: str) -> None:
|
||||||
|
assert msg.get("jsonrpc") == "2.0", f"[{step}] jsonrpc != '2.0': {msg}"
|
||||||
|
assert msg.get("id") == expected_id, f"[{step}] id != {expected_id}: {msg}"
|
||||||
|
assert "error" not in msg, f"[{step}] unexpected error field: {msg['error']}"
|
||||||
|
assert "result" in msg, f"[{step}] missing 'result' field: {msg}"
|
||||||
|
|
||||||
|
|
||||||
|
def _find_greet_tool(tools: list[dict[str, Any]]) -> str:
|
||||||
|
"""Return the MCP tool name that contains 'greet' (case-insensitive)."""
|
||||||
|
for t in tools:
|
||||||
|
name = str(t.get("name", ""))
|
||||||
|
if "greet" in name.lower():
|
||||||
|
return name
|
||||||
|
names = [t.get("name") for t in tools]
|
||||||
|
raise AssertionError(
|
||||||
|
"No tool containing 'greet' (case-insensitive) found.\n"
|
||||||
|
f"Available tools: {names}\n"
|
||||||
|
"Expected the generated server to expose a 'greet' tool "
|
||||||
|
"(e.g. MyServer_Greet from MCPApp(name='my_server') + def greet(...))."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_free_port() -> int:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
return int(s.getsockname()[1])
|
||||||
|
|
||||||
|
|
||||||
|
def _kill_process(proc: subprocess.Popen) -> None:
|
||||||
|
"""Terminate a subprocess, using taskkill on Windows to kill the tree."""
|
||||||
|
if proc.poll() is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
taskkill = shutil.which("taskkill") or "taskkill"
|
||||||
|
subprocess.run(
|
||||||
|
[taskkill, "/PID", str(proc.pid), "/T", "/F"],
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
proc.kill()
|
||||||
|
proc.wait(timeout=3)
|
||||||
|
|
||||||
|
|
||||||
|
def _drain_stderr(proc: subprocess.Popen, max_chars: int = 2000) -> str:
|
||||||
|
"""Non-blocking read of available stderr bytes for diagnostics."""
|
||||||
|
if proc.stderr is None:
|
||||||
|
return ""
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
if platform.system() != "Windows":
|
||||||
|
import select
|
||||||
|
|
||||||
|
ready, _, _ = select.select([proc.stderr], [], [], 0.1)
|
||||||
|
if ready:
|
||||||
|
return str(proc.stderr.read(max_chars))
|
||||||
|
else:
|
||||||
|
# On Windows select() doesn't work on pipes; try reading with timeout
|
||||||
|
import threading
|
||||||
|
|
||||||
|
buf: list[str] = []
|
||||||
|
|
||||||
|
def _reader() -> None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
buf.append(str(proc.stderr.read(max_chars))) # type: ignore[union-attr]
|
||||||
|
|
||||||
|
t = threading.Thread(target=_reader, daemon=True)
|
||||||
|
t.start()
|
||||||
|
t.join(timeout=0.5)
|
||||||
|
return buf[0] if buf else ""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _tail_text_file(path: str | None, max_chars: int = 4000) -> str:
|
||||||
|
"""Return the tail of a UTF-8 log file for diagnostics."""
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8", errors="replace") as f:
|
||||||
|
data = f.read()
|
||||||
|
return data[-max_chars:]
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stdio transport
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class StdioClient:
|
||||||
|
"""Communicate with an MCP server over stdin/stdout."""
|
||||||
|
|
||||||
|
def __init__(self, proc: subprocess.Popen, timeout: float = 30.0) -> None:
|
||||||
|
self.proc = proc
|
||||||
|
self.timeout = timeout
|
||||||
|
self._next_id = 1
|
||||||
|
self._stdout_queue: queue.Queue[str | None] = queue.Queue()
|
||||||
|
self._stdout_reader = threading.Thread(target=self._read_stdout_loop, daemon=True)
|
||||||
|
self._stdout_reader.start()
|
||||||
|
|
||||||
|
def _read_stdout_loop(self) -> None:
|
||||||
|
if self.proc.stdout is None:
|
||||||
|
self._stdout_queue.put(None)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
for raw in self.proc.stdout:
|
||||||
|
self._stdout_queue.put(raw)
|
||||||
|
finally:
|
||||||
|
self._stdout_queue.put(None)
|
||||||
|
|
||||||
|
def _next(self) -> int:
|
||||||
|
rid = self._next_id
|
||||||
|
self._next_id += 1
|
||||||
|
return rid
|
||||||
|
|
||||||
|
def send_request(self, method: str, params: dict[str, Any] | None = None) -> int:
|
||||||
|
rid = self._next()
|
||||||
|
line = json.dumps(_build_request(method, params, rid)) + "\n"
|
||||||
|
assert self.proc.stdin is not None
|
||||||
|
self.proc.stdin.write(line)
|
||||||
|
self.proc.stdin.flush()
|
||||||
|
return rid
|
||||||
|
|
||||||
|
def send_notification(self, method: str, params: dict[str, Any] | None = None) -> None:
|
||||||
|
line = json.dumps(_build_request(method, params)) + "\n"
|
||||||
|
assert self.proc.stdin is not None
|
||||||
|
self.proc.stdin.write(line)
|
||||||
|
self.proc.stdin.flush()
|
||||||
|
|
||||||
|
def read_response(self, expected_id: int) -> dict[str, Any]:
|
||||||
|
"""Read lines until we get a JSON-RPC message with the expected id."""
|
||||||
|
deadline = time.monotonic() + self.timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if self.proc.poll() is not None:
|
||||||
|
stderr_snippet = _drain_stderr(self.proc)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Server exited (code={self.proc.returncode}) while waiting for id={expected_id}.\n"
|
||||||
|
f"STDERR snippet:\n{stderr_snippet}"
|
||||||
|
)
|
||||||
|
timeout = min(0.5, max(0.0, deadline - time.monotonic()))
|
||||||
|
try:
|
||||||
|
raw = self._stdout_queue.get(timeout=timeout)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
if raw is None:
|
||||||
|
stderr_snippet = _drain_stderr(self.proc)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Server stdout closed (EOF) while waiting for id={expected_id}.\n"
|
||||||
|
f"STDERR snippet:\n{stderr_snippet}"
|
||||||
|
)
|
||||||
|
msg = _parse_json_line(raw)
|
||||||
|
if msg is None:
|
||||||
|
# Non-JSON line (server log/debug output) — skip
|
||||||
|
print(f" [stdio/log] {raw.rstrip()}", flush=True)
|
||||||
|
continue
|
||||||
|
if msg.get("id") != expected_id:
|
||||||
|
# Notification or out-of-order message — log and skip
|
||||||
|
print(f" [stdio/msg] {json.dumps(msg)}", flush=True)
|
||||||
|
continue
|
||||||
|
return msg
|
||||||
|
raise TimeoutError(f"Timed out after {self.timeout}s waiting for id={expected_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_stdio(server_dir: str, timeout: float) -> None:
|
||||||
|
print("\n=== Stdio transport MCP protocol smoke ===", flush=True)
|
||||||
|
proc: subprocess.Popen | None = None
|
||||||
|
stderr_sink = None
|
||||||
|
stderr_log_path: str | None = None
|
||||||
|
step = "startup"
|
||||||
|
last_response: dict[str, Any] = {}
|
||||||
|
try:
|
||||||
|
uv = shutil.which("uv") or "uv"
|
||||||
|
stderr_sink = tempfile.NamedTemporaryFile(
|
||||||
|
mode="w",
|
||||||
|
encoding="utf-8",
|
||||||
|
prefix="arcade-mcp-stdio-",
|
||||||
|
suffix=".log",
|
||||||
|
buffering=1,
|
||||||
|
delete=False,
|
||||||
|
)
|
||||||
|
stderr_log_path = stderr_sink.name
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[uv, "run", "server.py"],
|
||||||
|
cwd=server_dir,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=stderr_sink,
|
||||||
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Give the server a moment to initialize before we write to stdin
|
||||||
|
time.sleep(3)
|
||||||
|
if proc.poll() is not None:
|
||||||
|
stdout_out = proc.stdout.read() if proc.stdout else ""
|
||||||
|
stderr_out = proc.stderr.read() if proc.stderr else ""
|
||||||
|
raise RuntimeError( # noqa: TRY301
|
||||||
|
f"Server exited early (code={proc.returncode}).\n"
|
||||||
|
f"STDOUT:\n{stdout_out}\nSTDERR:\n{stderr_out}"
|
||||||
|
)
|
||||||
|
|
||||||
|
client = StdioClient(proc, timeout=timeout)
|
||||||
|
|
||||||
|
# 1. initialize
|
||||||
|
step = "initialize"
|
||||||
|
print(f"Step 1: {step}", flush=True)
|
||||||
|
init_id = client.send_request(
|
||||||
|
"initialize",
|
||||||
|
{
|
||||||
|
"protocolVersion": "2025-06-18",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": "arcade-windows-ci", "version": "0.1.0"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
last_response = client.read_response(expected_id=init_id)
|
||||||
|
_assert_ok(last_response, init_id, step)
|
||||||
|
server_info = last_response["result"].get("serverInfo", {})
|
||||||
|
print(f" OK — serverInfo={server_info}", flush=True)
|
||||||
|
|
||||||
|
# 2. notifications/initialized
|
||||||
|
step = "notifications/initialized"
|
||||||
|
print(f"Step 2: {step}", flush=True)
|
||||||
|
client.send_notification("notifications/initialized")
|
||||||
|
print(" OK — notification sent", flush=True)
|
||||||
|
|
||||||
|
# 3. ping
|
||||||
|
step = "ping"
|
||||||
|
print(f"Step 3: {step}", flush=True)
|
||||||
|
ping_id = client.send_request("ping")
|
||||||
|
last_response = client.read_response(expected_id=ping_id)
|
||||||
|
assert last_response.get("jsonrpc") == "2.0", f"[ping] jsonrpc != '2.0': {last_response}"
|
||||||
|
assert last_response.get("id") == ping_id, f"[ping] id mismatch: {last_response}"
|
||||||
|
assert "error" not in last_response, f"[ping] error in response: {last_response}"
|
||||||
|
print(" OK", flush=True)
|
||||||
|
|
||||||
|
# 4. tools/list
|
||||||
|
step = "tools/list"
|
||||||
|
print(f"Step 4: {step}", flush=True)
|
||||||
|
list_id = client.send_request("tools/list")
|
||||||
|
last_response = client.read_response(expected_id=list_id)
|
||||||
|
_assert_ok(last_response, list_id, step)
|
||||||
|
tools: list[dict[str, Any]] = last_response["result"].get("tools", [])
|
||||||
|
assert len(tools) > 0, f"[tools/list] empty tools list: {last_response}"
|
||||||
|
tool_names = [t.get("name") for t in tools]
|
||||||
|
print(f" OK — {len(tools)} tools: {tool_names}", flush=True)
|
||||||
|
|
||||||
|
# 5. tools/call greet
|
||||||
|
step = "tools/call(greet)"
|
||||||
|
print(f"Step 5: {step}", flush=True)
|
||||||
|
greet_name = _find_greet_tool(tools)
|
||||||
|
print(f" using tool: {greet_name!r}", flush=True)
|
||||||
|
call_id = client.send_request(
|
||||||
|
"tools/call",
|
||||||
|
{"name": greet_name, "arguments": {"name": "Windows CI"}},
|
||||||
|
)
|
||||||
|
last_response = client.read_response(expected_id=call_id)
|
||||||
|
_assert_ok(last_response, call_id, step)
|
||||||
|
content = last_response["result"].get("content", [])
|
||||||
|
assert len(content) > 0, f"[{step}] empty content array: {last_response}"
|
||||||
|
text: str = content[0].get("text", "")
|
||||||
|
assert "Hello" in text, (
|
||||||
|
f"[{step}] expected 'Hello' in response text.\n"
|
||||||
|
f" Got: {text!r}\n"
|
||||||
|
f" Full response: {last_response}"
|
||||||
|
)
|
||||||
|
print(f" OK — response: {text!r}", flush=True)
|
||||||
|
|
||||||
|
print("\nStdio transport smoke PASSED.", flush=True)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
if stderr_sink is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
stderr_sink.flush()
|
||||||
|
stderr_snippet = _tail_text_file(stderr_log_path) if stderr_log_path else ""
|
||||||
|
print(
|
||||||
|
f"\nSTDIO SMOKE FAILED at step '{step}'.\n"
|
||||||
|
f" Error: {exc}\n"
|
||||||
|
f" Last response: {json.dumps(last_response) if last_response else 'n/a'}\n"
|
||||||
|
f" Server STDERR snippet:\n{stderr_snippet}",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if stderr_sink is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
stderr_sink.close()
|
||||||
|
if proc is not None:
|
||||||
|
_kill_process(proc)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTTP transport
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _http_post(
|
||||||
|
url: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
extra_headers: dict[str, str] | None = None,
|
||||||
|
read_response_headers: bool = False,
|
||||||
|
timeout_seconds: float = 30.0,
|
||||||
|
) -> tuple[int, dict[str, str], dict[str, Any]]:
|
||||||
|
"""POST JSON payload, return (status, response_headers, body_dict)."""
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=body, method="POST") # noqa: S310
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
req.add_header("Accept", "application/json")
|
||||||
|
if extra_headers:
|
||||||
|
for k, v in extra_headers.items():
|
||||||
|
req.add_header(k, v)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout_seconds) as resp: # noqa: S310
|
||||||
|
status: int = resp.status
|
||||||
|
# http.client.HTTPMessage supports case-insensitive get()
|
||||||
|
resp_headers: dict[str, str] = {}
|
||||||
|
if read_response_headers:
|
||||||
|
for key in resp.headers:
|
||||||
|
resp_headers[key.lower()] = resp.headers[key]
|
||||||
|
raw = resp.read().decode("utf-8")
|
||||||
|
body_dict: dict[str, Any] = json.loads(raw) if raw.strip() else {}
|
||||||
|
return status, resp_headers, body_dict
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
status = e.code
|
||||||
|
raw = e.read().decode("utf-8")
|
||||||
|
try:
|
||||||
|
return status, {}, json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return status, {}, {"_raw": raw}
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_health(url: str, timeout: float) -> None:
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
req = urllib.request.Request(url) # noqa: S310
|
||||||
|
with urllib.request.urlopen(req, timeout=2) as resp: # noqa: S310
|
||||||
|
if resp.status == 200:
|
||||||
|
return
|
||||||
|
time.sleep(1)
|
||||||
|
raise TimeoutError(f"Server did not become healthy at {url} within {timeout}s")
|
||||||
|
|
||||||
|
|
||||||
|
def run_http(server_dir: str, timeout: float) -> None:
|
||||||
|
print("\n=== HTTP transport MCP protocol smoke ===", flush=True)
|
||||||
|
port = _find_free_port()
|
||||||
|
proc: subprocess.Popen | None = None
|
||||||
|
stdout_sink = None
|
||||||
|
stderr_sink = None
|
||||||
|
stdout_log_path: str | None = None
|
||||||
|
stderr_log_path: str | None = None
|
||||||
|
step = "startup"
|
||||||
|
last_response: dict[str, Any] = {}
|
||||||
|
try:
|
||||||
|
env = {
|
||||||
|
**os.environ,
|
||||||
|
"ARCADE_SERVER_HOST": "127.0.0.1",
|
||||||
|
"ARCADE_SERVER_PORT": str(port),
|
||||||
|
"ARCADE_WORKER_SECRET": "arcade-smoke-worker-secret",
|
||||||
|
}
|
||||||
|
uv = shutil.which("uv") or "uv"
|
||||||
|
stdout_sink = tempfile.NamedTemporaryFile(
|
||||||
|
mode="w",
|
||||||
|
encoding="utf-8",
|
||||||
|
prefix="arcade-mcp-http-out-",
|
||||||
|
suffix=".log",
|
||||||
|
buffering=1,
|
||||||
|
delete=False,
|
||||||
|
)
|
||||||
|
stdout_log_path = stdout_sink.name
|
||||||
|
stderr_sink = tempfile.NamedTemporaryFile(
|
||||||
|
mode="w",
|
||||||
|
encoding="utf-8",
|
||||||
|
prefix="arcade-mcp-http-err-",
|
||||||
|
suffix=".log",
|
||||||
|
buffering=1,
|
||||||
|
delete=False,
|
||||||
|
)
|
||||||
|
stderr_log_path = stderr_sink.name
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[uv, "run", "server.py", "http"],
|
||||||
|
cwd=server_dir,
|
||||||
|
stdout=stdout_sink,
|
||||||
|
stderr=stderr_sink,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
base_url = f"http://127.0.0.1:{port}"
|
||||||
|
health_url = f"{base_url}/worker/health"
|
||||||
|
mcp_url = f"{base_url}/mcp"
|
||||||
|
|
||||||
|
print(f" waiting for health at {health_url} (up to 30s)", flush=True)
|
||||||
|
_wait_for_health(health_url, timeout=30)
|
||||||
|
if proc.poll() is not None:
|
||||||
|
stderr_out = proc.stderr.read() if proc.stderr else ""
|
||||||
|
raise RuntimeError( # noqa: TRY301
|
||||||
|
f"Server exited (code={proc.returncode}) before health check passed.\n"
|
||||||
|
f"STDERR:\n{stderr_out}"
|
||||||
|
)
|
||||||
|
print(" health OK", flush=True)
|
||||||
|
|
||||||
|
session_headers: dict[str, str] = {}
|
||||||
|
|
||||||
|
# 1. initialize — capture mcp-session-id from response headers
|
||||||
|
step = "initialize"
|
||||||
|
print(f"Step 1: {step}", flush=True)
|
||||||
|
init_req = _build_request(
|
||||||
|
"initialize",
|
||||||
|
{
|
||||||
|
"protocolVersion": "2025-06-18",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": "arcade-windows-ci", "version": "0.1.0"},
|
||||||
|
},
|
||||||
|
req_id=1,
|
||||||
|
)
|
||||||
|
init_status, init_hdrs, last_response = _http_post(
|
||||||
|
mcp_url, init_req, read_response_headers=True, timeout_seconds=timeout
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
init_status == 200
|
||||||
|
), f"[{step}] expected status 200, got {init_status}: {last_response}"
|
||||||
|
assert last_response.get("jsonrpc") == "2.0", f"[{step}] jsonrpc != '2.0': {last_response}"
|
||||||
|
assert last_response.get("id") == 1, f"[{step}] id != 1: {last_response}"
|
||||||
|
assert "error" not in last_response, f"[{step}] error in response: {last_response}"
|
||||||
|
session_id = init_hdrs.get("mcp-session-id")
|
||||||
|
assert session_id is not None, (
|
||||||
|
f"[{step}] mcp-session-id header missing from initialize response.\n"
|
||||||
|
f" Headers received: {init_hdrs}"
|
||||||
|
)
|
||||||
|
session_headers["Mcp-Session-Id"] = session_id
|
||||||
|
server_info = last_response["result"].get("serverInfo", {})
|
||||||
|
print(f" OK — serverInfo={server_info}, session_id={session_id}", flush=True)
|
||||||
|
|
||||||
|
# 2. notifications/initialized
|
||||||
|
step = "notifications/initialized"
|
||||||
|
print(f"Step 2: {step}", flush=True)
|
||||||
|
notif_req = _build_request("notifications/initialized")
|
||||||
|
notif_status, _, _ = _http_post(mcp_url, notif_req, extra_headers=session_headers)
|
||||||
|
assert notif_status == 202, f"[{step}] expected status 202, got {notif_status}"
|
||||||
|
print(" OK (202)", flush=True)
|
||||||
|
|
||||||
|
# 3. ping
|
||||||
|
step = "ping"
|
||||||
|
print(f"Step 3: {step}", flush=True)
|
||||||
|
ping_status, _, last_response = _http_post(
|
||||||
|
mcp_url,
|
||||||
|
_build_request("ping", req_id=2),
|
||||||
|
extra_headers=session_headers,
|
||||||
|
timeout_seconds=timeout,
|
||||||
|
)
|
||||||
|
assert ping_status == 200, f"[{step}] expected 200, got {ping_status}: {last_response}"
|
||||||
|
assert last_response.get("jsonrpc") == "2.0", f"[{step}] jsonrpc: {last_response}"
|
||||||
|
assert last_response.get("id") == 2, f"[{step}] id: {last_response}"
|
||||||
|
assert "error" not in last_response, f"[{step}] error: {last_response}"
|
||||||
|
print(" OK", flush=True)
|
||||||
|
|
||||||
|
# 4. tools/list
|
||||||
|
step = "tools/list"
|
||||||
|
print(f"Step 4: {step}", flush=True)
|
||||||
|
list_status, _, last_response = _http_post(
|
||||||
|
mcp_url,
|
||||||
|
_build_request("tools/list", req_id=3),
|
||||||
|
extra_headers=session_headers,
|
||||||
|
timeout_seconds=timeout,
|
||||||
|
)
|
||||||
|
assert list_status == 200, f"[{step}] expected 200, got {list_status}: {last_response}"
|
||||||
|
_assert_ok(last_response, 3, step)
|
||||||
|
tools = last_response["result"].get("tools", [])
|
||||||
|
assert len(tools) > 0, f"[{step}] empty tools list: {last_response}"
|
||||||
|
tool_names = [t.get("name") for t in tools]
|
||||||
|
print(f" OK — {len(tools)} tools: {tool_names}", flush=True)
|
||||||
|
|
||||||
|
# 5. tools/call greet
|
||||||
|
step = "tools/call(greet)"
|
||||||
|
print(f"Step 5: {step}", flush=True)
|
||||||
|
greet_name = _find_greet_tool(tools)
|
||||||
|
print(f" using tool: {greet_name!r}", flush=True)
|
||||||
|
call_status, _, last_response = _http_post(
|
||||||
|
mcp_url,
|
||||||
|
_build_request(
|
||||||
|
"tools/call",
|
||||||
|
{"name": greet_name, "arguments": {"name": "Windows CI"}},
|
||||||
|
req_id=4,
|
||||||
|
),
|
||||||
|
extra_headers=session_headers,
|
||||||
|
timeout_seconds=timeout,
|
||||||
|
)
|
||||||
|
assert call_status == 200, f"[{step}] expected 200, got {call_status}: {last_response}"
|
||||||
|
_assert_ok(last_response, 4, step)
|
||||||
|
content = last_response["result"].get("content", [])
|
||||||
|
assert len(content) > 0, f"[{step}] empty content: {last_response}"
|
||||||
|
text: str = content[0].get("text", "")
|
||||||
|
assert "Hello" in text, (
|
||||||
|
f"[{step}] expected 'Hello' in response text.\n"
|
||||||
|
f" Got: {text!r}\n"
|
||||||
|
f" Full response: {last_response}"
|
||||||
|
)
|
||||||
|
print(f" OK — response: {text!r}", flush=True)
|
||||||
|
|
||||||
|
print("\nHTTP transport smoke PASSED.", flush=True)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
if stdout_sink is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
stdout_sink.flush()
|
||||||
|
if stderr_sink is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
stderr_sink.flush()
|
||||||
|
stdout_tail = _tail_text_file(stdout_log_path) if stdout_log_path else ""
|
||||||
|
stderr_tail = _tail_text_file(stderr_log_path) if stderr_log_path else ""
|
||||||
|
print(
|
||||||
|
f"\nHTTP SMOKE FAILED at step '{step}'.\n"
|
||||||
|
f" Error: {exc}\n"
|
||||||
|
f" Last response: {json.dumps(last_response) if last_response else 'n/a'}\n"
|
||||||
|
f" Server STDOUT snippet:\n{stdout_tail}\n"
|
||||||
|
f" Server STDERR snippet:\n{stderr_tail}",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if stdout_sink is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
stdout_sink.close()
|
||||||
|
if stderr_sink is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
stderr_sink.close()
|
||||||
|
if proc is not None:
|
||||||
|
_kill_process(proc)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="MCP protocol smoke test for a generated Arcade server."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--server-dir",
|
||||||
|
required=True,
|
||||||
|
help="Directory containing server.py (the generated server src dir).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--transport",
|
||||||
|
choices=["stdio", "http", "both"],
|
||||||
|
default="both",
|
||||||
|
help="Transport(s) to validate (default: both).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--timeout-seconds",
|
||||||
|
type=float,
|
||||||
|
default=30.0,
|
||||||
|
help="Per-step read timeout in seconds (default: 30).",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
server_dir = os.path.abspath(args.server_dir)
|
||||||
|
if not os.path.isdir(server_dir):
|
||||||
|
print(f"ERROR: --server-dir does not exist: {server_dir}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if not os.path.isfile(os.path.join(server_dir, "server.py")):
|
||||||
|
print(
|
||||||
|
f"ERROR: server.py not found in --server-dir: {server_dir}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Server dir : {server_dir}")
|
||||||
|
print(f"Transport : {args.transport}")
|
||||||
|
print(f"Timeout : {args.timeout_seconds}s per step")
|
||||||
|
|
||||||
|
failures: list[str] = []
|
||||||
|
|
||||||
|
if args.transport in ("stdio", "both"):
|
||||||
|
try:
|
||||||
|
run_stdio(server_dir, timeout=args.timeout_seconds)
|
||||||
|
except Exception as exc:
|
||||||
|
failures.append(f"stdio: {exc}")
|
||||||
|
|
||||||
|
if args.transport in ("http", "both"):
|
||||||
|
try:
|
||||||
|
run_http(server_dir, timeout=args.timeout_seconds)
|
||||||
|
except Exception as exc:
|
||||||
|
failures.append(f"http: {exc}")
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
print("\n=== MCP PROTOCOL SMOKE FAILURES ===", file=sys.stderr)
|
||||||
|
for msg in failures:
|
||||||
|
print(f" ✗ {msg}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("\nAll MCP protocol smoke checks PASSED.", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
299
tests/integration/no_auth_cli_smoke.py
Normal file
299
tests/integration/no_auth_cli_smoke.py
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Cross-platform no-auth CLI integration smoke checks.
|
||||||
|
|
||||||
|
This script runs a minimal but meaningful no-auth integration flow across all
|
||||||
|
CI operating systems:
|
||||||
|
1. Validate `arcade configure` writes client configs in a path with spaces.
|
||||||
|
2. Scaffold a new toolkit with `arcade new`.
|
||||||
|
3. Run protocol smoke checks (stdio + http) against the generated server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
|
||||||
|
def _run(
|
||||||
|
cmd: list[str],
|
||||||
|
*,
|
||||||
|
cwd: Path,
|
||||||
|
capture_output: bool = False,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=str(cwd),
|
||||||
|
text=True,
|
||||||
|
capture_output=capture_output,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Command failed ({proc.returncode}): {' '.join(cmd)}\n"
|
||||||
|
f"STDOUT:\n{proc.stdout or ''}\nSTDERR:\n{proc.stderr or ''}"
|
||||||
|
)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_exists(path: Path) -> None:
|
||||||
|
if not path.exists():
|
||||||
|
raise RuntimeError(f"Expected path to exist: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json_object(path: Path) -> dict[str, Any]:
|
||||||
|
parsed = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise TypeError(f"Expected JSON object in {path}, got {type(parsed).__name__}")
|
||||||
|
return cast(dict[str, Any], parsed)
|
||||||
|
|
||||||
|
|
||||||
|
def _expect_dict(value: Any, context: str) -> dict[str, Any]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise TypeError(f"Expected object for {context}, got {type(value).__name__}")
|
||||||
|
return cast(dict[str, Any], value)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_stdio_entry(entry: dict[str, Any], context: str) -> None:
|
||||||
|
if "command" not in entry:
|
||||||
|
raise RuntimeError(f"{context}: missing 'command'")
|
||||||
|
|
||||||
|
args = entry.get("args")
|
||||||
|
if not isinstance(args, list):
|
||||||
|
raise TypeError(f"{context}: missing or invalid 'args' list")
|
||||||
|
if not any(str(arg).endswith("server.py") for arg in args):
|
||||||
|
raise RuntimeError(f"{context}: expected entrypoint in args ending with 'server.py'")
|
||||||
|
|
||||||
|
|
||||||
|
def _add_local_uv_sources(pyproject_path: Path, repo_root: Path) -> None:
|
||||||
|
pyproject_text = pyproject_path.read_text(encoding="utf-8")
|
||||||
|
if "[tool.uv.sources]" in pyproject_text:
|
||||||
|
return
|
||||||
|
|
||||||
|
repo = repo_root.resolve()
|
||||||
|
block_lines = [
|
||||||
|
"[tool.uv.sources]",
|
||||||
|
f'arcade-mcp = {{ path = "{repo.as_posix()}", editable = true }}',
|
||||||
|
f'arcade-mcp-server = {{ path = "{(repo / "libs/arcade-mcp-server").as_posix()}", editable = true }}',
|
||||||
|
f'arcade-core = {{ path = "{(repo / "libs/arcade-core").as_posix()}", editable = true }}',
|
||||||
|
f'arcade-serve = {{ path = "{(repo / "libs/arcade-serve").as_posix()}", editable = true }}',
|
||||||
|
f'arcade-tdk = {{ path = "{(repo / "libs/arcade-tdk").as_posix()}", editable = true }}',
|
||||||
|
]
|
||||||
|
pyproject_path.write_text(
|
||||||
|
pyproject_text.rstrip() + "\n\n" + "\n".join(block_lines) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_configure_smoke(repo_root: Path) -> None:
|
||||||
|
config_tmp = Path(tempfile.mkdtemp(prefix="arcade mcp config test "))
|
||||||
|
try:
|
||||||
|
(config_tmp / "server.py").write_text("print('ok')\n", encoding="utf-8")
|
||||||
|
|
||||||
|
cursor_cfg = config_tmp / "cursor config.json"
|
||||||
|
vscode_cfg = config_tmp / "vscode config.json"
|
||||||
|
claude_cfg = config_tmp / "claude config.json"
|
||||||
|
|
||||||
|
_run(
|
||||||
|
[
|
||||||
|
"uv",
|
||||||
|
"run",
|
||||||
|
"--project",
|
||||||
|
str(repo_root),
|
||||||
|
"arcade",
|
||||||
|
"configure",
|
||||||
|
"cursor",
|
||||||
|
"--name",
|
||||||
|
"demo",
|
||||||
|
"--config",
|
||||||
|
str(cursor_cfg),
|
||||||
|
],
|
||||||
|
cwd=config_tmp,
|
||||||
|
)
|
||||||
|
cursor_data = _load_json_object(cursor_cfg)
|
||||||
|
cursor_mcp_servers = _expect_dict(cursor_data.get("mcpServers"), "Cursor stdio mcpServers")
|
||||||
|
_assert_stdio_entry(
|
||||||
|
_expect_dict(cursor_mcp_servers.get("demo"), "Cursor stdio demo server"), "Cursor stdio"
|
||||||
|
)
|
||||||
|
|
||||||
|
overwrite = _run(
|
||||||
|
[
|
||||||
|
"uv",
|
||||||
|
"run",
|
||||||
|
"--project",
|
||||||
|
str(repo_root),
|
||||||
|
"arcade",
|
||||||
|
"configure",
|
||||||
|
"cursor",
|
||||||
|
"--transport",
|
||||||
|
"http",
|
||||||
|
"--port",
|
||||||
|
"8123",
|
||||||
|
"--name",
|
||||||
|
"demo",
|
||||||
|
"--config",
|
||||||
|
str(cursor_cfg),
|
||||||
|
],
|
||||||
|
cwd=config_tmp,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
overwrite_output = (overwrite.stdout or "") + "\n" + (overwrite.stderr or "")
|
||||||
|
if "overwrite" not in overwrite_output.lower():
|
||||||
|
raise RuntimeError(
|
||||||
|
"Expected overwrite warning when configuring cursor with same --name.\n"
|
||||||
|
f"Output:\n{overwrite_output}"
|
||||||
|
)
|
||||||
|
cursor_data = _load_json_object(cursor_cfg)
|
||||||
|
cursor_mcp_servers = _expect_dict(cursor_data.get("mcpServers"), "Cursor http mcpServers")
|
||||||
|
cursor_http_demo = _expect_dict(cursor_mcp_servers.get("demo"), "Cursor http demo server")
|
||||||
|
if cursor_http_demo.get("type") != "stream":
|
||||||
|
raise RuntimeError("Cursor http config type mismatch")
|
||||||
|
if cursor_http_demo.get("url") != "http://localhost:8123/mcp":
|
||||||
|
raise RuntimeError("Cursor http config URL mismatch")
|
||||||
|
|
||||||
|
_run(
|
||||||
|
[
|
||||||
|
"uv",
|
||||||
|
"run",
|
||||||
|
"--project",
|
||||||
|
str(repo_root),
|
||||||
|
"arcade",
|
||||||
|
"configure",
|
||||||
|
"vscode",
|
||||||
|
"--name",
|
||||||
|
"demo",
|
||||||
|
"--config",
|
||||||
|
str(vscode_cfg),
|
||||||
|
],
|
||||||
|
cwd=config_tmp,
|
||||||
|
)
|
||||||
|
vscode_data = _load_json_object(vscode_cfg)
|
||||||
|
vscode_servers = _expect_dict(vscode_data.get("servers"), "VS Code stdio servers")
|
||||||
|
_assert_stdio_entry(
|
||||||
|
_expect_dict(vscode_servers.get("demo"), "VS Code stdio demo server"), "VS Code stdio"
|
||||||
|
)
|
||||||
|
|
||||||
|
_run(
|
||||||
|
[
|
||||||
|
"uv",
|
||||||
|
"run",
|
||||||
|
"--project",
|
||||||
|
str(repo_root),
|
||||||
|
"arcade",
|
||||||
|
"configure",
|
||||||
|
"vscode",
|
||||||
|
"--transport",
|
||||||
|
"http",
|
||||||
|
"--port",
|
||||||
|
"8123",
|
||||||
|
"--name",
|
||||||
|
"demo",
|
||||||
|
"--config",
|
||||||
|
str(vscode_cfg),
|
||||||
|
],
|
||||||
|
cwd=config_tmp,
|
||||||
|
)
|
||||||
|
vscode_data = _load_json_object(vscode_cfg)
|
||||||
|
vscode_servers = _expect_dict(vscode_data.get("servers"), "VS Code http servers")
|
||||||
|
vscode_http_demo = _expect_dict(vscode_servers.get("demo"), "VS Code http demo server")
|
||||||
|
if vscode_http_demo.get("type") != "http":
|
||||||
|
raise RuntimeError("VS Code http config type mismatch")
|
||||||
|
if vscode_http_demo.get("url") != "http://localhost:8123/mcp":
|
||||||
|
raise RuntimeError("VS Code http config URL mismatch")
|
||||||
|
|
||||||
|
_run(
|
||||||
|
[
|
||||||
|
"uv",
|
||||||
|
"run",
|
||||||
|
"--project",
|
||||||
|
str(repo_root),
|
||||||
|
"arcade",
|
||||||
|
"configure",
|
||||||
|
"claude",
|
||||||
|
"--name",
|
||||||
|
"demo",
|
||||||
|
"--config",
|
||||||
|
str(claude_cfg),
|
||||||
|
],
|
||||||
|
cwd=config_tmp,
|
||||||
|
)
|
||||||
|
claude_data = _load_json_object(claude_cfg)
|
||||||
|
claude_mcp_servers = _expect_dict(claude_data.get("mcpServers"), "Claude stdio mcpServers")
|
||||||
|
_assert_stdio_entry(
|
||||||
|
_expect_dict(claude_mcp_servers.get("demo"), "Claude stdio demo server"), "Claude stdio"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(config_tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_scaffold_and_protocol_smoke(repo_root: Path) -> None:
|
||||||
|
scaffold_dir = Path(tempfile.mkdtemp(prefix="arcade scaffold with spaces "))
|
||||||
|
try:
|
||||||
|
created = _run(
|
||||||
|
[
|
||||||
|
"uv",
|
||||||
|
"run",
|
||||||
|
"arcade",
|
||||||
|
"new",
|
||||||
|
"my_server",
|
||||||
|
"--dir",
|
||||||
|
str(scaffold_dir),
|
||||||
|
],
|
||||||
|
cwd=repo_root,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
new_output = (created.stdout or "") + "\n" + (created.stderr or "")
|
||||||
|
if "Next steps:" not in new_output:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Expected 'Next steps:' output from 'arcade new'.\n" f"Output:\n{new_output}"
|
||||||
|
)
|
||||||
|
|
||||||
|
generated_root = scaffold_dir / "my_server"
|
||||||
|
_ensure_exists(generated_root / "pyproject.toml")
|
||||||
|
_ensure_exists(generated_root / "src" / "my_server" / "server.py")
|
||||||
|
_ensure_exists(generated_root / "src" / "my_server" / ".env.example")
|
||||||
|
|
||||||
|
generated_pyproject = generated_root / "pyproject.toml"
|
||||||
|
_add_local_uv_sources(generated_pyproject, repo_root)
|
||||||
|
|
||||||
|
generated_server_dir = generated_root / "src" / "my_server"
|
||||||
|
_run(
|
||||||
|
["uv", "run", "python", "-c", "import server; print('generated server import ok')"],
|
||||||
|
cwd=generated_server_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
_run(
|
||||||
|
[
|
||||||
|
"uv",
|
||||||
|
"run",
|
||||||
|
"python",
|
||||||
|
"tests/integration/mcp_protocol_smoke.py",
|
||||||
|
"--server-dir",
|
||||||
|
str(generated_server_dir),
|
||||||
|
"--transport",
|
||||||
|
"both",
|
||||||
|
],
|
||||||
|
cwd=repo_root,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(scaffold_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
repo_root = Path.cwd().resolve()
|
||||||
|
print(f"Repo root: {repo_root}")
|
||||||
|
os.environ["ARCADE_USAGE_TRACKING"] = "0"
|
||||||
|
_run(["uv", "--version"], cwd=repo_root)
|
||||||
|
|
||||||
|
_run_configure_smoke(repo_root)
|
||||||
|
_run_scaffold_and_protocol_smoke(repo_root)
|
||||||
|
|
||||||
|
print("Cross-platform no-auth CLI smoke checks passed.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in a new issue