Replace fcntl with cross-platform portalocker (fix win/powershell errors) (#739)
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 <img width="934" height="501" alt="Screenshot 2026-01-08 at 2 57 46 PM" src="https://github.com/user-attachments/assets/1375b6b2-116c-44bd-bbe1-2157dd243d29" /> Closes ENGTOP-8 <!-- CURSOR_SUMMARY --> --- > [!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 > > <sup>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).</sup> <!-- /CURSOR_SUMMARY -->
This commit is contained in:
parent
98fad93d21
commit
c034046735
6 changed files with 495 additions and 14 deletions
48
.github/workflows/test-install.yml
vendored
Normal file
48
.github/workflows/test-install.yml
vendored
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
164
tests/install/README.md
Normal file
164
tests/install/README.md
Normal file
|
|
@ -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`
|
||||
1
tests/install/__init__.py
Normal file
1
tests/install/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Installation tests for arcade-mcp."""
|
||||
269
tests/install/test_install.py
Executable file
269
tests/install/test_install.py
Executable file
|
|
@ -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())
|
||||
Loading…
Reference in a new issue