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:
Evan Tahler 2026-01-09 12:34:36 -08:00 committed by GitHub
parent 98fad93d21
commit c034046735
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 495 additions and 14 deletions

48
.github/workflows/test-install.yml vendored Normal file
View 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

View file

@ -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

View file

@ -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
View 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`

View file

@ -0,0 +1 @@
"""Installation tests for arcade-mcp."""

269
tests/install/test_install.py Executable file
View 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())