chore: import upstream snapshot with attribution
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# Downloaded binaries and archives
|
||||
downloads/
|
||||
src/cua_driver/bin/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
@@ -0,0 +1,2 @@
|
||||
include README.md
|
||||
recursive-include src/cua_driver/bin *
|
||||
@@ -0,0 +1,53 @@
|
||||
# cua-driver
|
||||
|
||||
Python wrapper for [cua-driver](https://github.com/trycua/cua/tree/main/libs/cua-driver) - a cross-platform MCP (Model Context Protocol) server for computer-use automation.
|
||||
|
||||
## Installation
|
||||
|
||||
Install and usage docs live at https://cua.ai/docs/how-to-guides/driver/install
|
||||
and https://cua.ai/docs/reference/cua-driver/mcp-tools.
|
||||
|
||||
## Usage
|
||||
|
||||
The package provides a `cua-driver` command that wraps the native Rust binary.
|
||||
See the canonical tool reference at https://cua.ai/docs/reference/cua-driver/mcp-tools.
|
||||
|
||||
## Python API
|
||||
|
||||
You can also use the Python API directly:
|
||||
|
||||
```python
|
||||
from cua_driver import run_cua_driver, get_binary_path
|
||||
|
||||
# Run with custom args
|
||||
exit_code = run_cua_driver(["mcp"])
|
||||
|
||||
# Get path to bundled binary
|
||||
binary_path = get_binary_path()
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Cross-platform**: Works on macOS (universal), Linux (x86_64), and Windows (x86_64/ARM64)
|
||||
- **Zero dependencies**: Pure Python wrapper with no external dependencies
|
||||
- **Stdio passthrough**: Transparent piping for MCP protocol communication
|
||||
- **Bundled binary**: No separate installation required - the Rust binary is included in the wheel
|
||||
|
||||
## Platform Support
|
||||
|
||||
| Platform | Architecture | Status |
|
||||
|----------|-------------|---------|
|
||||
| macOS | Universal (ARM64 + x86_64) | ✅ Supported |
|
||||
| Linux | x86_64 | ✅ Supported |
|
||||
| Windows | x86_64 | ✅ Supported |
|
||||
| Windows | ARM64 | ✅ Supported |
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](https://github.com/trycua/cua/blob/main/LICENSE.md)
|
||||
|
||||
## Links
|
||||
|
||||
- [GitHub Repository](https://github.com/trycua/cua)
|
||||
- [Documentation](https://cua.ai/docs)
|
||||
- [Issue Tracker](https://github.com/trycua/cua/issues)
|
||||
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build script to download platform-specific cua-driver binary and create wheel.
|
||||
|
||||
This script:
|
||||
1. Detects the current platform
|
||||
2. Downloads the appropriate cua-driver-rs binary from GitHub releases
|
||||
3. Places it in src/cua_driver/bin/
|
||||
4. Builds the wheel with hatchling
|
||||
|
||||
Usage:
|
||||
python build_wheel.py [--version VERSION]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_default_version() -> str:
|
||||
"""Read the wrapper package version from pyproject.toml."""
|
||||
pyproject = Path(__file__).parent / "pyproject.toml"
|
||||
for line in pyproject.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("version = "):
|
||||
return line.split('"', 2)[1]
|
||||
raise RuntimeError(f"Could not read project version from {pyproject}")
|
||||
|
||||
|
||||
def get_platform_info(arch_override: str = None):
|
||||
"""Determine platform and architecture for binary selection.
|
||||
|
||||
Args:
|
||||
arch_override: Optional architecture override (e.g., 'arm64', 'x86_64', 'universal')
|
||||
"""
|
||||
system = platform.system().lower()
|
||||
|
||||
if arch_override:
|
||||
# Normalize arch_override to handle common aliases/casing
|
||||
arch_lower = arch_override.lower()
|
||||
if arch_lower in ("x86_64", "amd64", "x64"):
|
||||
arch = "x86_64"
|
||||
elif arch_lower in ("arm64", "aarch64"):
|
||||
arch = "arm64"
|
||||
elif arch_lower == "universal":
|
||||
arch = "universal"
|
||||
else:
|
||||
raise ValueError(f"Unsupported architecture override: {arch_override}")
|
||||
else:
|
||||
machine = platform.machine().lower()
|
||||
# Normalize architecture names
|
||||
if machine in ("x86_64", "amd64"):
|
||||
arch = "x86_64"
|
||||
elif machine in ("arm64", "aarch64"):
|
||||
arch = "arm64"
|
||||
else:
|
||||
raise ValueError(f"Unsupported architecture: {machine}")
|
||||
|
||||
# Map to cua-driver-rs release naming
|
||||
if system == "darwin":
|
||||
# macOS uses universal binary
|
||||
return "darwin", "universal"
|
||||
elif system == "linux":
|
||||
return "linux", arch
|
||||
elif system == "windows":
|
||||
return "windows", arch
|
||||
else:
|
||||
raise ValueError(f"Unsupported platform: {system}")
|
||||
|
||||
|
||||
def get_wheel_tag(platform_name: str, arch: str) -> str:
|
||||
"""Return the Python wheel tag for the bundled binary target."""
|
||||
if platform_name == "darwin":
|
||||
return "py3-none-macosx_11_0_universal2"
|
||||
if platform_name == "linux":
|
||||
# Rust Linux release binaries are built in debian:11, whose glibc floor is 2.31.
|
||||
if arch == "x86_64":
|
||||
return "py3-none-manylinux_2_31_x86_64"
|
||||
if arch == "arm64":
|
||||
return "py3-none-manylinux_2_31_aarch64"
|
||||
if platform_name == "windows":
|
||||
if arch == "x86_64":
|
||||
return "py3-none-win_amd64"
|
||||
if arch == "arm64":
|
||||
return "py3-none-win_arm64"
|
||||
|
||||
raise ValueError(f"Unsupported wheel target: {platform_name}-{arch}")
|
||||
|
||||
|
||||
def get_release_url(version: str, platform_name: str, arch: str) -> tuple[str, list[str]]:
|
||||
"""Get the GitHub release URL and binary names for the platform.
|
||||
|
||||
Returns:
|
||||
Tuple of (download_url, list_of_binary_names_in_archive)
|
||||
"""
|
||||
base_url = f"https://github.com/trycua/cua/releases/download/cua-driver-rs-v{version}"
|
||||
|
||||
if platform_name == "darwin":
|
||||
# Universal binary tarball
|
||||
filename = f"cua-driver-rs-{version}-darwin-universal-binary.tar.gz"
|
||||
binary_names = ["cua-driver"]
|
||||
elif platform_name == "linux":
|
||||
filename = f"cua-driver-rs-{version}-linux-{arch}-binary.tar.gz"
|
||||
binary_names = ["cua-driver"]
|
||||
elif platform_name == "windows":
|
||||
filename = f"cua-driver-rs-{version}-windows-{arch}-binary.zip"
|
||||
# Windows includes both main executable and UIAccess worker
|
||||
binary_names = ["cua-driver.exe", "cua-driver-uia.exe"]
|
||||
else:
|
||||
raise ValueError(f"Unknown platform: {platform_name}")
|
||||
|
||||
return f"{base_url}/{filename}", binary_names
|
||||
|
||||
|
||||
def verify_sha256(file_path: Path, expected_sha256: str) -> None:
|
||||
"""Verify file matches expected SHA256 hash."""
|
||||
h = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
h.update(chunk)
|
||||
actual = h.hexdigest()
|
||||
if actual != expected_sha256:
|
||||
raise ValueError(
|
||||
f"SHA256 mismatch for {file_path.name}: expected {expected_sha256}, got {actual}"
|
||||
)
|
||||
|
||||
|
||||
def get_expected_sha256(version: str, archive_name: str) -> str:
|
||||
"""Fetch and parse checksums.txt from GitHub release."""
|
||||
checksums_url = f"https://github.com/trycua/cua/releases/download/cua-driver-rs-v{version}/checksums.txt"
|
||||
print(f"Fetching checksums from {checksums_url}...")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(checksums_url) as response:
|
||||
content = response.read().decode("utf-8")
|
||||
|
||||
for line in content.splitlines():
|
||||
line = line.strip()
|
||||
# Skip empty lines, comments, and headers
|
||||
if not line or line.startswith("#") or "Checksums" in line or line.startswith("```"):
|
||||
continue
|
||||
# Parse "SHA256 filename" format
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
sha, name = parts[0], parts[-1]
|
||||
if name == archive_name:
|
||||
return sha
|
||||
|
||||
raise ValueError(f"SHA256 for {archive_name} not found in checksums.txt")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to fetch or parse checksums: {e}")
|
||||
|
||||
|
||||
def download_file(url: str, dest: Path, expected_sha256: str) -> None:
|
||||
"""Download a file with progress indication and SHA256 verification."""
|
||||
print(f"Downloading {url}...")
|
||||
try:
|
||||
with urllib.request.urlopen(url) as response:
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(dest, "wb") as f:
|
||||
downloaded = 0
|
||||
block_size = 8192
|
||||
while True:
|
||||
chunk = response.read(block_size)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if total_size > 0:
|
||||
percent = (downloaded / total_size) * 100
|
||||
print(f" {percent:.1f}% ({downloaded}/{total_size} bytes)", end="\r")
|
||||
|
||||
print(f"\nDownloaded to {dest}")
|
||||
|
||||
# Verify SHA256
|
||||
print("Verifying SHA256 checksum...")
|
||||
verify_sha256(dest, expected_sha256)
|
||||
print("[OK] Checksum verified")
|
||||
|
||||
except Exception as e:
|
||||
if dest.exists():
|
||||
dest.unlink()
|
||||
raise RuntimeError(f"Failed to download {url}: {e}")
|
||||
|
||||
|
||||
def extract_binaries(archive_path: Path, binary_names: list[str], dest_dir: Path) -> list[Path]:
|
||||
"""Extract the binaries from the archive.
|
||||
|
||||
Args:
|
||||
archive_path: Path to the archive file
|
||||
binary_names: List of binary names to extract
|
||||
dest_dir: Destination directory
|
||||
|
||||
Returns:
|
||||
List of paths to extracted binaries
|
||||
"""
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
extracted_paths = []
|
||||
|
||||
if archive_path.suffix == ".zip":
|
||||
with zipfile.ZipFile(archive_path, "r") as zf:
|
||||
for binary_name in binary_names:
|
||||
dest_path = dest_dir / binary_name
|
||||
# Find the binary in the zip (exact match or ends with /<binary_name>)
|
||||
for name in zf.namelist():
|
||||
# Match exact name or path ending with /binary_name
|
||||
if name == binary_name or name.endswith(f"/{binary_name}"):
|
||||
with zf.open(name) as src, open(dest_path, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
extracted_paths.append(dest_path)
|
||||
print(f"Extracted binary to {dest_path}")
|
||||
break
|
||||
else:
|
||||
raise ValueError(f"Binary {binary_name} not found in {archive_path}")
|
||||
else:
|
||||
# .tar.gz
|
||||
with tarfile.open(archive_path, "r:gz") as tf:
|
||||
for binary_name in binary_names:
|
||||
# The -binary tarballs have the binary at the root
|
||||
tf.extract(binary_name, dest_dir)
|
||||
dest_path = dest_dir / binary_name
|
||||
extracted_paths.append(dest_path)
|
||||
print(f"Extracted binary to {dest_path}")
|
||||
|
||||
# Make executable on Unix
|
||||
if sys.platform != "win32":
|
||||
for path in extracted_paths:
|
||||
os.chmod(path, 0o755)
|
||||
|
||||
return extracted_paths
|
||||
|
||||
|
||||
def build_wheel(package_dir: Path, wheel_tag: str, target_arch: str = None) -> None:
|
||||
"""Build the wheel using hatchling.
|
||||
|
||||
Args:
|
||||
package_dir: Directory containing pyproject.toml
|
||||
wheel_tag: Platform-specific wheel tag to write.
|
||||
target_arch: Target architecture for cross-compilation (x86_64, arm64)
|
||||
"""
|
||||
print("\nBuilding wheel...")
|
||||
|
||||
env = os.environ.copy()
|
||||
env["CUA_DRIVER_WHEEL_TAG"] = wheel_tag
|
||||
|
||||
# Set platform tag override for cross-compilation
|
||||
if target_arch:
|
||||
system = platform.system().lower()
|
||||
if system == "windows":
|
||||
# Override wheel platform tag for Windows cross-compilation
|
||||
if target_arch == "arm64":
|
||||
env["_PYTHON_HOST_PLATFORM"] = "win-arm64"
|
||||
elif target_arch == "x86_64":
|
||||
env["_PYTHON_HOST_PLATFORM"] = "win-amd64"
|
||||
# Linux and macOS don't need overrides for our use case
|
||||
# (macOS uses universal binary, Linux builds on native arch)
|
||||
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "build", "--wheel"],
|
||||
cwd=package_dir,
|
||||
env=env,
|
||||
check=True,
|
||||
)
|
||||
print("Wheel built successfully!")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Build cua-driver Python wheel with bundled binary")
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
help="cua-driver-rs version to download (default: pyproject.toml version)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--arch",
|
||||
help="Architecture override (e.g., 'arm64', 'x86_64', 'universal')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-download",
|
||||
action="store_true",
|
||||
help="Skip download and use existing binary in bin/ (for local testing)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
version = args.version or get_default_version()
|
||||
|
||||
# Determine paths
|
||||
script_dir = Path(__file__).parent
|
||||
bin_dir = script_dir / "src" / "cua_driver" / "bin"
|
||||
download_dir = script_dir / "downloads"
|
||||
platform_name, arch = get_platform_info(args.arch)
|
||||
wheel_tag = get_wheel_tag(platform_name, arch)
|
||||
print(f"Wheel tag: {wheel_tag}")
|
||||
|
||||
if not args.skip_download:
|
||||
# Get platform info and download URL
|
||||
print(f"Building for {platform_name}-{arch}")
|
||||
|
||||
url, binary_names = get_release_url(version, platform_name, arch)
|
||||
archive_name = url.split("/")[-1]
|
||||
archive_path = download_dir / archive_name
|
||||
|
||||
# Get expected SHA256 from checksums.txt
|
||||
expected_sha256 = get_expected_sha256(version, archive_name)
|
||||
|
||||
# Download the release archive (or verify cached)
|
||||
if not archive_path.exists():
|
||||
download_file(url, archive_path, expected_sha256)
|
||||
else:
|
||||
print(f"Using cached archive: {archive_path}")
|
||||
# Verify cached archive too
|
||||
print("Verifying cached archive SHA256...")
|
||||
verify_sha256(archive_path, expected_sha256)
|
||||
print("[OK] Cached archive checksum verified")
|
||||
|
||||
# Extract binaries
|
||||
extract_binaries(archive_path, binary_names, bin_dir)
|
||||
else:
|
||||
print("Skipping download (using existing binary)")
|
||||
|
||||
# Verify main binary exists
|
||||
expected_binary = "cua-driver.exe" if sys.platform == "win32" else "cua-driver"
|
||||
binary_path = bin_dir / expected_binary
|
||||
if not binary_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Binary not found at {binary_path}. "
|
||||
f"Run without --skip-download or place binary manually."
|
||||
)
|
||||
|
||||
print(f"\nBinary ready at: {binary_path}")
|
||||
print(f"Binary size: {binary_path.stat().st_size / 1024 / 1024:.2f} MB")
|
||||
|
||||
# List all binaries in bin directory
|
||||
print(f"\nAll binaries in {bin_dir}:")
|
||||
for binary in bin_dir.iterdir():
|
||||
if binary.is_file():
|
||||
print(f" - {binary.name} ({binary.stat().st_size / 1024 / 1024:.2f} MB)")
|
||||
|
||||
# Build the wheel (pass target arch for cross-compilation)
|
||||
build_wheel(script_dir, wheel_tag, target_arch=arch)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Hatch build hook for platform-tagged cua-driver wheels."""
|
||||
|
||||
import os
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||
|
||||
|
||||
class CustomBuildHook(BuildHookInterface):
|
||||
"""Apply the wheel tag selected by build_wheel.py."""
|
||||
|
||||
def initialize(self, version: str, build_data: dict) -> None:
|
||||
wheel_tag = os.environ.get("CUA_DRIVER_WHEEL_TAG")
|
||||
if not wheel_tag:
|
||||
return
|
||||
|
||||
build_data["tag"] = wheel_tag
|
||||
build_data["pure_python"] = False
|
||||
@@ -0,0 +1,59 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "cua-driver"
|
||||
version = "0.7.1"
|
||||
description = "Python wrapper for cua-driver - cross-platform MCP server for computer-use automation"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
authors = [
|
||||
{ name = "TryCua", email = "hello@trycua.com" }
|
||||
]
|
||||
keywords = [
|
||||
"mcp",
|
||||
"computer-use",
|
||||
"automation",
|
||||
"accessibility",
|
||||
"screen-capture",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: MacOS",
|
||||
"Operating System :: Microsoft :: Windows",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
]
|
||||
requires-python = ">=3.10"
|
||||
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/trycua/cua"
|
||||
Documentation = "https://github.com/trycua/cua/tree/main/libs/cua-driver"
|
||||
Repository = "https://github.com/trycua/cua"
|
||||
Issues = "https://github.com/trycua/cua/issues"
|
||||
|
||||
[project.scripts]
|
||||
cua-driver = "cua_driver.__main__:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/cua_driver"]
|
||||
artifacts = [
|
||||
"src/cua_driver/bin/*",
|
||||
]
|
||||
pure-python = false
|
||||
|
||||
[tool.hatch.build.targets.wheel.hooks.custom]
|
||||
path = "hatch_build.py"
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Python wrapper for cua-driver - cross-platform MCP server.
|
||||
|
||||
This package provides a thin Python wrapper around the cua-driver Rust binary,
|
||||
enabling pip-installable access to the MCP server for computer-use automation.
|
||||
"""
|
||||
|
||||
__version__ = "0.7.1"
|
||||
|
||||
from .wrapper import run_cua_driver, get_binary_path
|
||||
|
||||
__all__ = ["run_cua_driver", "get_binary_path", "__version__"]
|
||||
@@ -0,0 +1,20 @@
|
||||
"""CLI entry point for cua-driver Python wrapper.
|
||||
|
||||
This module is invoked when running:
|
||||
python -m cua_driver [args...]
|
||||
or via the installed script:
|
||||
cua-driver [args...]
|
||||
"""
|
||||
|
||||
import sys
|
||||
from .wrapper import run_cua_driver
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point for the cua-driver CLI."""
|
||||
exit_code = run_cua_driver()
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Subprocess wrapper for cua-driver binary with stdio passthrough."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def get_binary_path() -> Path:
|
||||
"""Get the path to the bundled cua-driver binary.
|
||||
|
||||
Returns:
|
||||
Path to the cua-driver executable.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the binary is not found in the package.
|
||||
"""
|
||||
# Binary is bundled in the package at: cua_driver/bin/cua-driver[.exe]
|
||||
package_dir = Path(__file__).parent
|
||||
|
||||
if sys.platform == "win32":
|
||||
binary_name = "cua-driver.exe"
|
||||
else:
|
||||
binary_name = "cua-driver"
|
||||
|
||||
binary_path = package_dir / "bin" / binary_name
|
||||
|
||||
if not binary_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"cua-driver binary not found at {binary_path}. "
|
||||
f"This package may not have been built correctly for {sys.platform}."
|
||||
)
|
||||
|
||||
# Ensure binary is executable on Unix
|
||||
if sys.platform != "win32":
|
||||
os.chmod(binary_path, 0o755)
|
||||
|
||||
return binary_path
|
||||
|
||||
|
||||
def run_cua_driver(args: Optional[list[str]] = None) -> int:
|
||||
"""Execute cua-driver binary with stdio passthrough.
|
||||
|
||||
Args:
|
||||
args: Command-line arguments to pass to cua-driver.
|
||||
If None, uses sys.argv[1:].
|
||||
|
||||
Returns:
|
||||
Exit code from the cua-driver process.
|
||||
"""
|
||||
if args is None:
|
||||
args = sys.argv[1:]
|
||||
|
||||
binary_path = get_binary_path()
|
||||
|
||||
try:
|
||||
# Run with direct stdio inheritance - no buffering, no capturing
|
||||
result = subprocess.run(
|
||||
[str(binary_path), *args],
|
||||
stdin=sys.stdin,
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
return result.returncode
|
||||
except KeyboardInterrupt:
|
||||
# Standard SIGINT exit code
|
||||
return 130
|
||||
except Exception as e:
|
||||
print(f"Error executing cua-driver: {e}", file=sys.stderr)
|
||||
return 1
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Tests for cua-driver wheel build helpers."""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_build_wheel_module():
|
||||
module_path = Path(__file__).resolve().parents[1] / "build_wheel.py"
|
||||
spec = importlib.util.spec_from_file_location("build_wheel", module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_wheel_tags_are_platform_specific():
|
||||
build_wheel = load_build_wheel_module()
|
||||
|
||||
assert build_wheel.get_wheel_tag("darwin", "universal") == "py3-none-macosx_11_0_universal2"
|
||||
assert build_wheel.get_wheel_tag("linux", "x86_64") == "py3-none-manylinux_2_31_x86_64"
|
||||
assert build_wheel.get_wheel_tag("linux", "arm64") == "py3-none-manylinux_2_31_aarch64"
|
||||
assert build_wheel.get_wheel_tag("windows", "x86_64") == "py3-none-win_amd64"
|
||||
assert build_wheel.get_wheel_tag("windows", "arm64") == "py3-none-win_arm64"
|
||||
|
||||
|
||||
def test_license_metadata_stays_legacy_upload_compatible():
|
||||
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
||||
pyproject_text = pyproject.read_text()
|
||||
|
||||
assert 'license = { text = "MIT" }' in pyproject_text
|
||||
assert 'license = "MIT"' not in pyproject_text
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests for the cua-driver Python wrapper."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_get_binary_path():
|
||||
"""Test that get_binary_path returns a valid path."""
|
||||
from cua_driver.wrapper import get_binary_path
|
||||
|
||||
# This will raise FileNotFoundError if binary doesn't exist
|
||||
# In CI, we need to build the package first for this to pass
|
||||
try:
|
||||
binary_path = get_binary_path()
|
||||
assert binary_path.exists()
|
||||
assert binary_path.name in ("cua-driver", "cua-driver.exe")
|
||||
except FileNotFoundError:
|
||||
# Expected in development without building
|
||||
pytest.skip("Binary not bundled yet (run build_wheel.py first)")
|
||||
|
||||
|
||||
def test_run_cua_driver_version(monkeypatch):
|
||||
"""Test running cua-driver --version through the wrapper."""
|
||||
from cua_driver.wrapper import run_cua_driver, get_binary_path
|
||||
|
||||
try:
|
||||
binary_path = get_binary_path()
|
||||
except FileNotFoundError:
|
||||
pytest.skip("Binary not bundled yet")
|
||||
|
||||
# Run with --version
|
||||
exit_code = run_cua_driver(["--version"])
|
||||
assert exit_code == 0
|
||||
|
||||
|
||||
def test_wrapper_preserves_exit_code():
|
||||
"""Test that the wrapper preserves the binary's exit code."""
|
||||
from cua_driver.wrapper import run_cua_driver, get_binary_path
|
||||
|
||||
try:
|
||||
binary_path = get_binary_path()
|
||||
except FileNotFoundError:
|
||||
pytest.skip("Binary not bundled yet")
|
||||
|
||||
# Invalid command should return non-zero
|
||||
exit_code = run_cua_driver(["--this-flag-does-not-exist"])
|
||||
assert exit_code != 0
|
||||
|
||||
|
||||
@patch("cua_driver.wrapper.subprocess.run")
|
||||
@patch("cua_driver.wrapper.get_binary_path")
|
||||
def test_subprocess_args(mock_get_binary, mock_run):
|
||||
"""Test that subprocess is called with correct arguments."""
|
||||
from cua_driver.wrapper import run_cua_driver
|
||||
|
||||
mock_binary = Path("/fake/path/cua-driver")
|
||||
mock_get_binary.return_value = mock_binary
|
||||
mock_run.return_value = Mock(returncode=0)
|
||||
|
||||
run_cua_driver(["mcp", "--help"])
|
||||
|
||||
mock_run.assert_called_once()
|
||||
call_args = mock_run.call_args
|
||||
assert call_args[0][0] == [str(mock_binary), "mcp", "--help"]
|
||||
assert call_args[1]["stdin"] == sys.stdin
|
||||
assert call_args[1]["stdout"] == sys.stdout
|
||||
assert call_args[1]["stderr"] == sys.stderr
|
||||
|
||||
|
||||
@patch("cua_driver.wrapper.subprocess.run")
|
||||
@patch("cua_driver.wrapper.get_binary_path")
|
||||
def test_keyboard_interrupt_handling(mock_get_binary, mock_run):
|
||||
"""Test that KeyboardInterrupt returns exit code 130."""
|
||||
from cua_driver.wrapper import run_cua_driver
|
||||
|
||||
mock_binary = Path("/fake/path/cua-driver")
|
||||
mock_get_binary.return_value = mock_binary
|
||||
mock_run.side_effect = KeyboardInterrupt()
|
||||
|
||||
exit_code = run_cua_driver(["mcp"])
|
||||
assert exit_code == 130
|
||||
Reference in New Issue
Block a user