From c034046735f1393ec40ced03e871a42bf0c5f190 Mon Sep 17 00:00:00 2001 From: Evan Tahler Date: Fri, 9 Jan 2026 12:34:36 -0800 Subject: [PATCH] Replace fcntl with cross-platform portalocker (fix win/powershell errors) (#739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit So even importing `fcntl` causes problems on windows. This PR replaces fcntl with portalocker. Tests all pass, so I think we are good. ref: https://arcade-ai.slack.com/archives/C08K1SJ072S/p1767897850450239?thread_ts=1766186586.406019&cid=C08K1SJ072S Screenshot 2026-01-08 at 2 57 46 PM Closes ENGTOP-8 --- > [!NOTE] > **Cross-platform file locking** > > - Replace `fcntl` with `portalocker` in `arcade_core/usage/identity.py` (shared/exclusive locks); switch to atomic `os.replace()` > - Add `portalocker` dependency and bump `arcade-core` to `4.2.1` > > **Installation/CI** > > - New GitHub Actions workflow `test-install.yml` runs install/CLI checks on macOS, Windows, and Linux for Python 3.10/3.12 > - Add `tests/install/test_install.py` and README to verify install, `arcade` CLI availability, and `portalocker` locking behavior > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3fe98fbcbf177f51fdb0b7fc51b20060f7fc85ad. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/workflows/test-install.yml | 48 ++++ .../arcade-core/arcade_core/usage/identity.py | 24 +- libs/arcade-core/pyproject.toml | 3 +- tests/install/README.md | 164 +++++++++++ tests/install/__init__.py | 1 + tests/install/test_install.py | 269 ++++++++++++++++++ 6 files changed, 495 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/test-install.yml create mode 100644 tests/install/README.md create mode 100644 tests/install/__init__.py create mode 100755 tests/install/test_install.py diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml new file mode 100644 index 00000000..9811dc49 --- /dev/null +++ b/.github/workflows/test-install.yml @@ -0,0 +1,48 @@ +name: Test Installation (Cross-Platform) + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + test-install: + name: Test Installation on ${{ matrix.os }} with Python ${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.10", "3.12"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: "latest" + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + uv sync --dev + + - name: Install arcade-mcp from source + run: | + uv pip install -e . + + - name: Run installation test + run: | + python tests/install/test_install.py + + - name: Verify arcade CLI is available + run: | + uv run arcade --help diff --git a/libs/arcade-core/arcade_core/usage/identity.py b/libs/arcade-core/arcade_core/usage/identity.py index 4418f1ca..99d6b233 100644 --- a/libs/arcade-core/arcade_core/usage/identity.py +++ b/libs/arcade-core/arcade_core/usage/identity.py @@ -6,7 +6,6 @@ supporting pre-login anonymous tracking, post-login identity stitching, and logout identity rotation. """ -import fcntl import json import os import tempfile @@ -14,6 +13,7 @@ import uuid from typing import Any import httpx +import portalocker import yaml from arcade_core.constants import ARCADE_CONFIG_PATH, CREDENTIALS_FILE_PATH @@ -46,18 +46,16 @@ class UsageIdentity: if os.path.exists(self.usage_file_path): try: with open(self.usage_file_path) as f: - # lock file - if os.name != "nt": # Unix-like systems - fcntl.flock(f.fileno(), fcntl.LOCK_SH) + # Lock file for reading (shared lock) + portalocker.lock(f, portalocker.LOCK_SH) try: data = json.load(f) if isinstance(data, dict) and KEY_ANON_ID in data: self._data = data return self._data finally: - # unlock file - if os.name != "nt": - fcntl.flock(f.fileno(), fcntl.LOCK_UN) + # Unlock file + portalocker.unlock(f) except Exception: # noqa: S110 pass @@ -80,18 +78,18 @@ class UsageIdentity: try: with os.fdopen(temp_fd, "w") as f: - # lock file - if os.name != "nt": # Unix-like systems - fcntl.flock(f.fileno(), fcntl.LOCK_EX) + # Lock file for writing (exclusive lock) + portalocker.lock(f, portalocker.LOCK_EX) try: json.dump(data, f, indent=2) f.flush() os.fsync(f.fileno()) # ensure data is written to disk finally: - if os.name != "nt": - fcntl.flock(f.fileno(), fcntl.LOCK_UN) + portalocker.unlock(f) - os.rename(temp_path, self.usage_file_path) + # Use os.replace() for cross-platform atomic file replacement + # os.replace() is atomic on both Unix and Windows (Python 3.3+) + os.replace(temp_path, self.usage_file_path) except Exception: # clean up import contextlib diff --git a/libs/arcade-core/pyproject.toml b/libs/arcade-core/pyproject.toml index 47751bde..dc7fb6a1 100644 --- a/libs/arcade-core/pyproject.toml +++ b/libs/arcade-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-core" -version = "4.2.0" +version = "4.2.1" description = "Arcade Core - Core library for Arcade platform" readme = "README.md" license = { text = "MIT" } @@ -24,6 +24,7 @@ dependencies = [ "toml>=0.10.2", "httpx>=0.27.0", "packaging>=24.1", + "portalocker>=2.10.0", "types-python-dateutil==2.9.0.20241003", "types-pytz==2024.2.0.20241003", "types-toml==0.10.8.20240310", diff --git a/tests/install/README.md b/tests/install/README.md new file mode 100644 index 00000000..bae582fa --- /dev/null +++ b/tests/install/README.md @@ -0,0 +1,164 @@ +# Installation Tests + +This directory contains tests to verify that `arcade-mcp` can be installed from source and works correctly across different platforms. + +## Overview + +The installation test (`test_install.py`) verifies: + +1. **Prerequisites**: Checks that required tools (like `uv`) are available +2. **Installation**: Installs `arcade-mcp` from source using `uv` +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`) + +## Running Locally + +### Prerequisites + +- Python 3.10 or higher +- [uv](https://github.com/astral-sh/uv) installed and available in PATH + +### Quick Start + +From the project root: + +```bash +uv run python tests/install/test_install.py +``` + +Or if you have the package already installed: + +```bash +python tests/install/test_install.py +``` + +### Direct Execution + +The script is executable, so you can also run it directly: + +```bash +./tests/install/test_install.py +``` + +Or with Python: + +```bash +python3 tests/install/test_install.py +``` + +## What the Test Does + +1. **Checks Prerequisites** + - Verifies `uv` is installed and available + +2. **Installs Package** + - Syncs dependencies with `uv sync --dev` + - Installs `arcade-mcp` in editable mode from source + +3. **Tests CLI** + - Verifies `arcade --help` works + - Tests `arcade --version` (optional) + - Tests `arcade whoami` (may fail if not logged in, but shouldn't crash) + +4. **Tests File Locking** + - Creates a temporary identity file + - Tests shared lock for reading + - Tests exclusive lock for writing + - Verifies `portalocker` works cross-platform (Windows, macOS, Linux) + +## Expected Output + +On success, you should see: + +``` +============================================================ +Testing arcade-mcp Installation from Source +============================================================ + +Project root: /path/to/arcade-mcp + +============================================================ +Prerequisites Check +============================================================ +✅ Success: Check uv availability + +============================================================ +Installation Phase +============================================================ +✅ Success: Sync dependencies with uv +✅ Success: Install arcade-mcp from source (editable mode) + +============================================================ +CLI Functionality Tests +============================================================ +✅ Success: Verify arcade CLI is available (--help) +✅ Success: Check arcade version +✅ Success: Test whoami command (no auth required) + +============================================================ +File Locking Tests (portalocker) +============================================================ +✅ Success: Test portalocker file locking (cross-platform) + +============================================================ +Test Summary +============================================================ +✅ PASSED: Check uv availability +✅ PASSED: Sync dependencies with uv +✅ PASSED: Install arcade-mcp from source (editable mode) +✅ PASSED: Verify arcade CLI is available (--help) +... + +Total: X/X tests passed + +🎉 All tests passed! arcade-mcp is working correctly. +``` + +## Running in CI/CD + +This test is automatically run in GitHub Actions on: +- macOS (latest) +- Windows (latest) +- Linux (Ubuntu latest) + +For Python versions: 3.10, 3.11, and 3.12 + +See `.github/workflows/test-install.yml` for the CI configuration. + +## Troubleshooting + +### `uv` not found + +If you get an error that `uv` is not available: + +```bash +# Install uv +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +Or using pip: + +```bash +pip install uv +``` + +### Permission Denied + +If you get a permission error when running the script directly: + +```bash +chmod +x tests/install/test_install.py +``` + +### Import Errors + +If you see import errors, make sure you're running from the project root and that dependencies are installed: + +```bash +uv sync --dev +``` + +## Related Files + +- `.github/workflows/test-install.yml` - GitHub Actions workflow +- `libs/arcade-core/arcade_core/usage/identity.py` - File locking implementation using `portalocker` diff --git a/tests/install/__init__.py b/tests/install/__init__.py new file mode 100644 index 00000000..317154f8 --- /dev/null +++ b/tests/install/__init__.py @@ -0,0 +1 @@ +"""Installation tests for arcade-mcp.""" diff --git a/tests/install/test_install.py b/tests/install/test_install.py new file mode 100755 index 00000000..71e34f42 --- /dev/null +++ b/tests/install/test_install.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +""" +Test script to verify arcade-mcp installation from source. + +This script: +1. Installs arcade-mcp from source using uv +2. Runs basic CLI commands to verify functionality +3. Tests cross-platform compatibility (file locking with portalocker) +""" + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +# Ensure UTF-8 encoding for cross-platform compatibility (especially Windows) +if sys.platform == "win32": + # Set UTF-8 encoding for Windows console + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8") + # Set environment variable for subprocesses + os.environ["PYTHONIOENCODING"] = "utf-8" + + +class TestRunner: + """Organizes and runs installation tests for arcade-mcp.""" + + def __init__(self, project_root: Path): + """Initialize test runner with project root.""" + self.project_root = project_root + self.arcade_cmd = self._find_arcade_command() + self.test_results: list[tuple[str, bool]] = [] + + def _find_arcade_command(self) -> list[str]: + """Find the arcade command (either direct or via uv run).""" + if shutil.which("arcade"): + return ["arcade"] + return ["uv", "run", "arcade"] + + def run_command( + self, cmd: list[str], description: str, required: bool = True + ) -> tuple[bool, str]: + """Run a command and return success status and output.""" + print(f"\n{'=' * 60}") + print(f"Testing: {description}") + print(f"Command: {' '.join(cmd)}") + print(f"{'=' * 60}") + + # Ensure UTF-8 encoding for cross-platform compatibility (especially Windows) + env = os.environ.copy() + env["PYTHONIOENCODING"] = "utf-8" + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=True, + timeout=60, + env=env, + encoding="utf-8", + errors="replace", + ) + except subprocess.TimeoutExpired: + print(f"❌ Timeout: {description}") + self.test_results.append((description, False)) + return False, "Command timed out" + except subprocess.CalledProcessError as e: + print(f"❌ Failed: {description}") + print(f"Return code: {e.returncode}") + if e.stdout: + print(f"Stdout:\n{e.stdout}") + if e.stderr: + print(f"Stderr:\n{e.stderr}") + self.test_results.append((description, False)) + if required: + return False, e.stderr or e.stdout or "" + return False, e.stderr or e.stdout or "" + except Exception as e: + print(f"❌ Error: {description}") + print(f"Exception: {e}") + self.test_results.append((description, False)) + if required: + return False, str(e) + return False, str(e) + else: + print(f"✅ Success: {description}") + if result.stdout: + print(f"Output:\n{result.stdout}") + self.test_results.append((description, True)) + output = result.stdout + return True, output + + def check_prerequisites(self) -> bool: + """Check that required tools are available.""" + print("\n" + "=" * 60) + print("Prerequisites Check") + print("=" * 60) + + success, _ = self.run_command(["uv", "--version"], "Check uv availability", required=True) + return success + + def install_package(self) -> bool: + """Install arcade-mcp from source.""" + print("\n" + "=" * 60) + print("Installation Phase") + print("=" * 60) + + # Sync dependencies + sync_success, _ = self.run_command( + ["uv", "sync", "--dev"], + "Sync dependencies with uv", + required=True, + ) + if not sync_success: + return False + + # Install package in editable mode + install_success, _ = self.run_command( + ["uv", "pip", "install", "-e", str(self.project_root)], + "Install arcade-mcp from source (editable mode)", + required=True, + ) + return install_success + + def test_cli_availability(self) -> bool: + """Test that the CLI is available and working.""" + print("\n" + "=" * 60) + print("CLI Functionality Tests") + print("=" * 60) + + # Test --help + help_success, _ = self.run_command( + [*self.arcade_cmd, "--help"], + "Verify arcade CLI is available (--help)", + required=True, + ) + if not help_success: + return False + + # Test --version (optional, might not exist) + self.run_command( + [*self.arcade_cmd, "--version"], + "Check arcade version", + required=False, + ) + + # Test whoami (might fail if not logged in, but shouldn't crash) + self.run_command( + [*self.arcade_cmd, "whoami"], + "Test whoami command (no auth required)", + required=False, + ) + + return True + + def test_file_locking(self) -> bool: + """Test cross-platform file locking with portalocker.""" + print("\n" + "=" * 60) + print("File Locking Tests (portalocker)") + print("=" * 60) + + test_code = """ +import tempfile +import json +from pathlib import Path +from arcade_core.usage.identity import UsageIdentity +import os + +# Create a temporary directory for testing +with tempfile.TemporaryDirectory() as tmpdir: + # Monkey patch the config path + import arcade_core.usage.identity as identity_module + original_path = identity_module.ARCADE_CONFIG_PATH + identity_module.ARCADE_CONFIG_PATH = tmpdir + + try: + # Create identity instance and test file operations + identity = UsageIdentity() + + # Test load_or_create (uses file locking) + data1 = identity.load_or_create() + assert "anon_id" in data1 + print(f"✅ Successfully created identity with anon_id: {data1['anon_id']}") + + # Test that we can read it back (uses shared lock) + identity2 = UsageIdentity() + data2 = identity2.load_or_create() + assert data2["anon_id"] == data1["anon_id"] + print(f"✅ Successfully read identity with file locking") + + # Test atomic write (uses exclusive lock) + identity.set_linked_principal_id("test-principal-123") + data3 = identity.load_or_create() + assert data3["linked_principal_id"] == "test-principal-123" + print(f"✅ Successfully wrote identity with file locking") + + print("✅ All portalocker file locking tests passed!") + finally: + identity_module.ARCADE_CONFIG_PATH = original_path +""" + + # Use uv run to ensure we're in the right environment + python_cmd = ["uv", "run", "python", "-c", test_code] + success, _ = self.run_command( + python_cmd, + "Test portalocker file locking (cross-platform)", + required=True, + ) + return success + + def run_all_tests(self) -> int: + """Run all tests and return exit code.""" + print("=" * 60) + print("Testing arcade-mcp Installation from Source") + print("=" * 60) + print(f"\nProject root: {self.project_root}") + + # Run test phases + test_phases = [ + ("Prerequisites", self.check_prerequisites), + ("Installation", self.install_package), + ("CLI Functionality", self.test_cli_availability), + ("File Locking", self.test_file_locking), + ] + + for phase_name, test_func in test_phases: + if not test_func(): + print(f"\n❌ {phase_name} phase failed. Stopping tests.") + return 1 + + # Print summary + self.print_summary() + return 0 + + def print_summary(self) -> None: + """Print test summary.""" + print("\n" + "=" * 60) + print("Test Summary") + print("=" * 60) + + passed = sum(1 for _, success in self.test_results if success) + total = len(self.test_results) + + for description, success in self.test_results: + status = "✅ PASSED" if success else "❌ FAILED" + print(f"{status}: {description}") + + print(f"\nTotal: {passed}/{total} tests passed") + + if passed == total: + print("\n🎉 All tests passed! arcade-mcp is working correctly.") + else: + print(f"\n⚠️ {total - passed} test(s) failed.") + + +def main() -> int: + """Main entry point.""" + # Get project root (two levels up from tests/install/) + project_root = Path(__file__).parent.parent.parent.absolute() + runner = TestRunner(project_root) + return runner.run_all_tests() + + +if __name__ == "__main__": + sys.exit(main())