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,14 @@
|
||||
[bumpversion]
|
||||
current_version = 0.1.2
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = auto-v{new_version}
|
||||
message = Bump cua-auto to v{new_version}
|
||||
|
||||
[bumpversion:file:pyproject.toml]
|
||||
search = version = "{current_version}"
|
||||
replace = version = "{new_version}"
|
||||
|
||||
[bumpversion:file:cua_auto/__init__.py]
|
||||
search = __version__ = "{current_version}"
|
||||
replace = __version__ = "{new_version}"
|
||||
@@ -0,0 +1,62 @@
|
||||
# cua-auto
|
||||
|
||||
`cua-auto` is a lightweight, cross-platform automation library providing a synchronous, MIT-licensed pyautogui-style API for mouse, keyboard, screen, window, clipboard, and shell operations. It runs on Windows, macOS, and Linux using [pynput](https://github.com/moses-palmer/pynput) for input control and [pywinctl](https://github.com/Kalmat/PyWinCtl) for window management.
|
||||
|
||||
```python
|
||||
import cua_auto.mouse as mouse
|
||||
import cua_auto.keyboard as keyboard
|
||||
import cua_auto.screen as screen
|
||||
import cua_auto.window as window
|
||||
import cua_auto.clipboard as clipboard
|
||||
import cua_auto.shell as shell
|
||||
|
||||
# Mouse
|
||||
mouse.click(100, 200) # left click
|
||||
mouse.right_click(100, 200)
|
||||
mouse.double_click(100, 200)
|
||||
mouse.move_to(500, 300)
|
||||
mouse.mouse_down(100, 200)
|
||||
mouse.mouse_up(100, 200)
|
||||
mouse.drag(100, 200, 400, 500) # start → end
|
||||
mouse.scroll_up(3)
|
||||
mouse.scroll_down(3)
|
||||
x, y = mouse.position()
|
||||
|
||||
# Keyboard
|
||||
keyboard.press_key("enter")
|
||||
keyboard.type_text("hello world")
|
||||
keyboard.hotkey(["ctrl", "c"])
|
||||
keyboard.key_down("shift")
|
||||
keyboard.key_up("shift")
|
||||
|
||||
# Screen
|
||||
img = screen.screenshot() # returns PIL.Image
|
||||
png = screen.screenshot_bytes() # raw PNG bytes
|
||||
b64 = screen.screenshot_b64() # base64 string
|
||||
w, h = screen.screen_size()
|
||||
x, y = screen.cursor_position()
|
||||
|
||||
# Window
|
||||
title = window.get_active_window_title()
|
||||
handle = window.get_active_window_handle()
|
||||
handles = window.get_windows_with_title("Chrome")
|
||||
name = window.get_window_name(handle)
|
||||
x, y = window.get_window_position(handle)
|
||||
w, h = window.get_window_size(handle)
|
||||
window.activate_window(handle)
|
||||
window.minimize_window(handle)
|
||||
window.maximize_window(handle)
|
||||
window.close_window(handle)
|
||||
window.set_window_size(handle, 1280, 800)
|
||||
window.set_window_position(handle, 0, 0)
|
||||
window.open("https://example.com") # or file path
|
||||
pid = window.launch("notepad.exe")
|
||||
|
||||
# Clipboard
|
||||
text = clipboard.get()
|
||||
clipboard.set("hello")
|
||||
|
||||
# Shell
|
||||
result = shell.run("echo hi") # CommandResult
|
||||
print(result.stdout, result.returncode)
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
"""cua-auto — cross-platform automation library.
|
||||
|
||||
Provides a synchronous, pyautogui-style API for mouse, keyboard, screen,
|
||||
window, clipboard, and shell operations.
|
||||
|
||||
Usage::
|
||||
|
||||
import cua_auto.mouse as mouse
|
||||
import cua_auto.keyboard as keyboard
|
||||
import cua_auto.screen as screen
|
||||
import cua_auto.window as window
|
||||
import cua_auto.clipboard as clipboard
|
||||
import cua_auto.shell as shell
|
||||
|
||||
mouse.click(100, 200)
|
||||
keyboard.hotkey(["ctrl", "c"])
|
||||
img = screen.screenshot()
|
||||
title = window.get_active_window_title()
|
||||
"""
|
||||
|
||||
__version__ = "0.1.2"
|
||||
|
||||
# terminal and shell have no display dependency — always safe to import.
|
||||
from cua_auto import shell, terminal
|
||||
|
||||
# These modules require a display server (pynput, PIL, pywinctl, pyperclip).
|
||||
# Guard them so that headless environments (CI without X, computer-server inside
|
||||
# a container) can still import cua_auto.terminal / cua_auto.shell without error.
|
||||
try:
|
||||
from cua_auto import clipboard, keyboard, mouse, screen, window
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
__all__ = ["mouse", "keyboard", "screen", "window", "clipboard", "shell", "terminal"]
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Platform detection helpers."""
|
||||
|
||||
import platform as _platform
|
||||
|
||||
_sys = _platform.system().lower()
|
||||
|
||||
IS_WINDOWS: bool = _sys == "windows"
|
||||
IS_MACOS: bool = _sys == "darwin"
|
||||
IS_LINUX: bool = _sys == "linux"
|
||||
|
||||
PLATFORM: str = "windows" if IS_WINDOWS else ("macos" if IS_MACOS else "linux")
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Cross-platform clipboard access via pyperclip."""
|
||||
|
||||
import pyperclip as _pyperclip
|
||||
|
||||
|
||||
def get() -> str:
|
||||
"""Return the current clipboard text content."""
|
||||
return _pyperclip.paste()
|
||||
|
||||
|
||||
def set(text: str) -> None:
|
||||
"""Set the clipboard text content."""
|
||||
_pyperclip.copy(text)
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Cross-platform keyboard control via pynput."""
|
||||
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from pynput.keyboard import Controller as _KBController
|
||||
from pynput.keyboard import Key as _Key
|
||||
|
||||
_kb = _KBController()
|
||||
|
||||
# Unified key name → pynput Key map (sourced from computer-server/handlers/windows.py)
|
||||
_SPECIAL: dict = {
|
||||
"enter": _Key.enter,
|
||||
"return": _Key.enter,
|
||||
"esc": _Key.esc,
|
||||
"escape": _Key.esc,
|
||||
"space": _Key.space,
|
||||
"tab": _Key.tab,
|
||||
"backspace": _Key.backspace,
|
||||
"delete": _Key.delete,
|
||||
"home": _Key.home,
|
||||
"end": _Key.end,
|
||||
"pageup": _Key.page_up,
|
||||
"page_up": _Key.page_up,
|
||||
"pagedown": _Key.page_down,
|
||||
"page_down": _Key.page_down,
|
||||
"up": _Key.up,
|
||||
"down": _Key.down,
|
||||
"left": _Key.left,
|
||||
"right": _Key.right,
|
||||
"shift": _Key.shift,
|
||||
"shift_l": _Key.shift_l,
|
||||
"shift_r": _Key.shift_r,
|
||||
"ctrl": _Key.ctrl,
|
||||
"ctrl_l": _Key.ctrl_l,
|
||||
"ctrl_r": _Key.ctrl_r,
|
||||
"control": _Key.ctrl,
|
||||
"alt": _Key.alt,
|
||||
"alt_l": _Key.alt_l,
|
||||
"alt_r": _Key.alt_r,
|
||||
"cmd": _Key.cmd,
|
||||
"command": _Key.cmd,
|
||||
"win": _Key.cmd,
|
||||
"super": _Key.cmd,
|
||||
"meta": _Key.cmd,
|
||||
"option": _Key.alt,
|
||||
"option_l": _Key.alt_l,
|
||||
"option_r": _Key.alt_r,
|
||||
"capslock": _Key.caps_lock,
|
||||
"caps_lock": _Key.caps_lock,
|
||||
"f1": _Key.f1,
|
||||
"f2": _Key.f2,
|
||||
"f3": _Key.f3,
|
||||
"f4": _Key.f4,
|
||||
"f5": _Key.f5,
|
||||
"f6": _Key.f6,
|
||||
"f7": _Key.f7,
|
||||
"f8": _Key.f8,
|
||||
"f9": _Key.f9,
|
||||
"f10": _Key.f10,
|
||||
"f11": _Key.f11,
|
||||
"f12": _Key.f12,
|
||||
"f13": _Key.f13,
|
||||
"f14": _Key.f14,
|
||||
"f15": _Key.f15,
|
||||
"f16": _Key.f16,
|
||||
"f17": _Key.f17,
|
||||
"f18": _Key.f18,
|
||||
"f19": _Key.f19,
|
||||
"f20": _Key.f20,
|
||||
}
|
||||
|
||||
# Keys that may not exist on all platforms (e.g. macOS lacks insert, print_screen)
|
||||
for _name, _attr in [
|
||||
("insert", "insert"),
|
||||
("print_screen", "print_screen"),
|
||||
("pause", "pause"),
|
||||
("num_lock", "num_lock"),
|
||||
("scroll_lock", "scroll_lock"),
|
||||
]:
|
||||
_val = getattr(_Key, _attr, None)
|
||||
if _val is not None:
|
||||
_SPECIAL[_name] = _val
|
||||
|
||||
|
||||
def _resolve(key: str) -> Optional[Union[str, _Key]]:
|
||||
"""Resolve a key string to a pynput Key or single character."""
|
||||
if not key:
|
||||
return None
|
||||
lk = key.lower()
|
||||
if lk in _SPECIAL:
|
||||
return _SPECIAL[lk]
|
||||
if len(key) == 1:
|
||||
return key
|
||||
return None
|
||||
|
||||
|
||||
# ── Key press / release ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def key_down(key: str) -> None:
|
||||
"""Press and hold a key."""
|
||||
k = _resolve(key)
|
||||
if k is None:
|
||||
raise ValueError(f"Unknown key: {key!r}")
|
||||
_kb.press(k)
|
||||
|
||||
|
||||
def key_up(key: str) -> None:
|
||||
"""Release a key."""
|
||||
k = _resolve(key)
|
||||
if k is None:
|
||||
raise ValueError(f"Unknown key: {key!r}")
|
||||
_kb.release(k)
|
||||
|
||||
|
||||
def press_key(key: str) -> None:
|
||||
"""Press and immediately release a key."""
|
||||
k = _resolve(key)
|
||||
if k is None:
|
||||
raise ValueError(f"Unknown key: {key!r}")
|
||||
_kb.press(k)
|
||||
_kb.release(k)
|
||||
|
||||
|
||||
# ── Text typing ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def type_text(text: str) -> None:
|
||||
"""Type a string of text (supports Unicode)."""
|
||||
_kb.type(text)
|
||||
|
||||
|
||||
# ── Hotkeys ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def hotkey(keys: List[str]) -> None:
|
||||
"""Press a combination of keys, e.g. ['ctrl', 'c'].
|
||||
|
||||
Modifiers are held while the last key is tapped, then released in reverse
|
||||
order (standard hotkey behaviour).
|
||||
"""
|
||||
resolved = [_resolve(k) for k in keys]
|
||||
if any(k is None for k in resolved):
|
||||
bad = [k for k, r in zip(keys, resolved) if r is None]
|
||||
raise ValueError(f"Unknown keys in hotkey: {bad}")
|
||||
|
||||
seq: List[Union[str, _Key]] = [k for k in resolved if k is not None]
|
||||
if not seq:
|
||||
return
|
||||
|
||||
# Hold all modifiers except the last key
|
||||
for k in seq[:-1]:
|
||||
_kb.press(k)
|
||||
# Tap the action key
|
||||
last = seq[-1]
|
||||
_kb.press(last)
|
||||
_kb.release(last)
|
||||
# Release modifiers in reverse order
|
||||
for k in reversed(seq[:-1]):
|
||||
_kb.release(k)
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Cross-platform mouse control via pynput."""
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from pynput.mouse import Button as _Button
|
||||
from pynput.mouse import Controller as _MouseController
|
||||
|
||||
_mouse = _MouseController()
|
||||
|
||||
|
||||
def _map_button(button: str) -> _Button:
|
||||
"""Map a button name string to a pynput Button."""
|
||||
b = (button or "left").lower()
|
||||
if b == "right":
|
||||
return _Button.right
|
||||
if b == "middle":
|
||||
return _Button.middle
|
||||
return _Button.left
|
||||
|
||||
|
||||
# ── Basic clicks ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def click(x: int, y: int, button: str = "left") -> None:
|
||||
"""Single click at (x, y)."""
|
||||
_mouse.position = (x, y)
|
||||
_mouse.click(_map_button(button), 1)
|
||||
|
||||
|
||||
def right_click(x: int, y: int) -> None:
|
||||
"""Right-click at (x, y)."""
|
||||
click(x, y, "right")
|
||||
|
||||
|
||||
def double_click(x: int, y: int) -> None:
|
||||
"""Double left-click at (x, y)."""
|
||||
_mouse.position = (x, y)
|
||||
_mouse.click(_Button.left, 2)
|
||||
|
||||
|
||||
# ── Cursor movement ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def move_to(x: int, y: int) -> None:
|
||||
"""Move cursor to (x, y) without clicking."""
|
||||
_mouse.position = (x, y)
|
||||
|
||||
|
||||
# ── Mouse button hold / release ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def mouse_down(x: Optional[int] = None, y: Optional[int] = None, button: str = "left") -> None:
|
||||
"""Press and hold a mouse button, optionally moving to (x, y) first."""
|
||||
if x is not None and y is not None:
|
||||
_mouse.position = (x, y)
|
||||
_mouse.press(_map_button(button))
|
||||
|
||||
|
||||
def mouse_up(x: Optional[int] = None, y: Optional[int] = None, button: str = "left") -> None:
|
||||
"""Release a mouse button, optionally moving to (x, y) first."""
|
||||
if x is not None and y is not None:
|
||||
_mouse.position = (x, y)
|
||||
_mouse.release(_map_button(button))
|
||||
|
||||
|
||||
# ── Drag ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def drag(
|
||||
start_x: int,
|
||||
start_y: int,
|
||||
end_x: int,
|
||||
end_y: int,
|
||||
button: str = "left",
|
||||
) -> None:
|
||||
"""Drag from (start_x, start_y) to (end_x, end_y)."""
|
||||
btn = _map_button(button)
|
||||
_mouse.position = (start_x, start_y)
|
||||
_mouse.press(btn)
|
||||
_mouse.position = (end_x, end_y)
|
||||
_mouse.release(btn)
|
||||
|
||||
|
||||
def drag_to(x: int, y: int, button: str = "left") -> None:
|
||||
"""Drag from the current cursor position to (x, y)."""
|
||||
btn = _map_button(button)
|
||||
_mouse.press(btn)
|
||||
_mouse.position = (x, y)
|
||||
_mouse.release(btn)
|
||||
|
||||
|
||||
def drag_path(path: List[Tuple[int, int]], button: str = "left") -> None:
|
||||
"""Drag through a sequence of (x, y) points."""
|
||||
if not path:
|
||||
return
|
||||
btn = _map_button(button)
|
||||
_mouse.position = path[0]
|
||||
_mouse.press(btn)
|
||||
for x, y in path[1:]:
|
||||
_mouse.position = (x, y)
|
||||
_mouse.release(btn)
|
||||
|
||||
|
||||
# ── Scroll ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def scroll(dx: int, dy: int) -> None:
|
||||
"""Scroll by (dx, dy). Positive dy = scroll up."""
|
||||
_mouse.scroll(dx, dy)
|
||||
|
||||
|
||||
def scroll_up(clicks: int = 3) -> None:
|
||||
"""Scroll up by *clicks* notches."""
|
||||
_mouse.scroll(0, abs(clicks))
|
||||
|
||||
|
||||
def scroll_down(clicks: int = 3) -> None:
|
||||
"""Scroll down by *clicks* notches."""
|
||||
_mouse.scroll(0, -abs(clicks))
|
||||
|
||||
|
||||
def scroll_left(clicks: int = 3) -> None:
|
||||
"""Scroll left by *clicks* notches."""
|
||||
_mouse.scroll(-abs(clicks), 0)
|
||||
|
||||
|
||||
def scroll_right(clicks: int = 3) -> None:
|
||||
"""Scroll right by *clicks* notches."""
|
||||
_mouse.scroll(abs(clicks), 0)
|
||||
|
||||
|
||||
# ── Position ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def position() -> Tuple[int, int]:
|
||||
"""Return the current cursor position as (x, y)."""
|
||||
x, y = _mouse.position
|
||||
return int(x), int(y)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Cross-platform screen capture and info."""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import platform
|
||||
from typing import Tuple
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def _get_display_scale() -> float:
|
||||
"""Detect the OS display scale factor (DPI scaling).
|
||||
|
||||
Returns the ratio of physical pixels to logical pixels:
|
||||
- macOS Retina: typically 2.0
|
||||
- Windows 150% DPI: 1.5
|
||||
- Linux HiDPI (Wayland/X11): reads GDK_SCALE or QT_SCALE_FACTOR
|
||||
- Falls back to 1.0 on failure or unknown platforms.
|
||||
"""
|
||||
system = platform.system()
|
||||
|
||||
# macOS: use AppKit to query the main screen backing scale factor
|
||||
if system == "Darwin":
|
||||
try:
|
||||
from AppKit import NSScreen # type: ignore[import-untyped]
|
||||
|
||||
return float(NSScreen.mainScreen().backingScaleFactor())
|
||||
except Exception:
|
||||
return 1.0
|
||||
|
||||
# Windows: use shcore to get the monitor scale factor
|
||||
if system == "Windows":
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
return ctypes.windll.shcore.GetScaleFactorForDevice(0) / 100.0 # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
return 1.0
|
||||
|
||||
# Linux: check common env vars set by Wayland/X11 compositors and toolkits
|
||||
if system == "Linux":
|
||||
try:
|
||||
gdk = os.environ.get("GDK_SCALE")
|
||||
if gdk:
|
||||
return float(gdk)
|
||||
qt = os.environ.get("QT_SCALE_FACTOR")
|
||||
if qt:
|
||||
return float(qt)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return 1.0
|
||||
|
||||
return 1.0
|
||||
|
||||
|
||||
def get_display_scale() -> float:
|
||||
"""Return the OS display scale factor (public API).
|
||||
|
||||
This is a convenience wrapper around _get_display_scale() for use by
|
||||
external code (e.g. MCP servers) that need to know the current DPI scale.
|
||||
"""
|
||||
return _get_display_scale()
|
||||
|
||||
|
||||
def screenshot() -> Image.Image:
|
||||
"""Capture a screenshot of all monitors and return a PIL Image.
|
||||
|
||||
The returned image is normalized to logical pixels so that its coordinate
|
||||
space matches pynput mouse input (which operates in logical coordinates).
|
||||
|
||||
Tries PIL.ImageGrab first (works on Windows and macOS).
|
||||
Falls back to mss if ImageGrab fails (better Linux / multi-monitor support).
|
||||
"""
|
||||
# Try PIL.ImageGrab (Windows + macOS)
|
||||
try:
|
||||
from PIL import ImageGrab
|
||||
|
||||
img = ImageGrab.grab(all_screens=True)
|
||||
if isinstance(img, Image.Image):
|
||||
scale = _get_display_scale()
|
||||
if scale > 1.0:
|
||||
img = img.resize(
|
||||
(int(img.width / scale), int(img.height / scale)),
|
||||
Image.LANCZOS,
|
||||
)
|
||||
return img
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: mss (cross-platform, optional dependency)
|
||||
try:
|
||||
import mss # type: ignore[import-untyped]
|
||||
import mss.tools # type: ignore[import-untyped]
|
||||
|
||||
with mss.mss() as sct:
|
||||
monitor = sct.monitors[0] # combined virtual desktop
|
||||
sct_img = sct.grab(monitor)
|
||||
img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
|
||||
scale = _get_display_scale()
|
||||
if scale > 1.0:
|
||||
img = img.resize(
|
||||
(int(img.width / scale), int(img.height / scale)),
|
||||
Image.LANCZOS,
|
||||
)
|
||||
return img
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"screenshot failed: {e}") from e
|
||||
|
||||
|
||||
def screenshot_bytes(format: str = "PNG") -> bytes:
|
||||
"""Return screenshot as raw bytes in the specified image format."""
|
||||
img = screenshot()
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format=format)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def screenshot_b64(format: str = "PNG") -> str:
|
||||
"""Return screenshot as a base64-encoded string."""
|
||||
return base64.b64encode(screenshot_bytes(format)).decode()
|
||||
|
||||
|
||||
def screen_size() -> Tuple[int, int]:
|
||||
"""Return the total virtual desktop size as (width, height)."""
|
||||
img = screenshot()
|
||||
return img.size
|
||||
|
||||
|
||||
def cursor_position() -> Tuple[int, int]:
|
||||
"""Return the current cursor position as (x, y).
|
||||
|
||||
Tries win32gui first on Windows (more reliable with DPI scaling).
|
||||
Falls back to pynput if win32 is unavailable (cross-platform fallback).
|
||||
"""
|
||||
try:
|
||||
import win32gui # type: ignore[import-untyped]
|
||||
|
||||
pos = win32gui.GetCursorPos()
|
||||
return int(pos[0]), int(pos[1])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from pynput.mouse import Controller as _MouseController
|
||||
|
||||
c = _MouseController()
|
||||
x, y = c.position
|
||||
return int(x), int(y)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Shell command execution."""
|
||||
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
stdout: str
|
||||
stderr: str
|
||||
returncode: int
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.returncode == 0
|
||||
|
||||
|
||||
def run(command: str, timeout: int = 30) -> CommandResult:
|
||||
"""Run a shell command and return stdout, stderr, and returncode.
|
||||
|
||||
The command is passed to the system shell (``shell=True``) so that
|
||||
shell built-ins, pipes, and redirections work as expected.
|
||||
"""
|
||||
|
||||
def _decode(data: bytes) -> str:
|
||||
if not data:
|
||||
return ""
|
||||
for enc in ("utf-8", "gbk", "gb2312", "cp936", "latin1"):
|
||||
try:
|
||||
return data.decode(enc)
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
continue
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
)
|
||||
return CommandResult(
|
||||
stdout=_decode(result.stdout),
|
||||
stderr=_decode(result.stderr),
|
||||
returncode=result.returncode,
|
||||
)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
return CommandResult(
|
||||
stdout=_decode(e.stdout or b""),
|
||||
stderr=_decode(e.stderr or b""),
|
||||
returncode=-1,
|
||||
)
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Cross-platform PTY (pseudo-terminal) manager.
|
||||
|
||||
Uses the ``pty`` stdlib on Unix/macOS and ``pywinpty`` on Windows.
|
||||
|
||||
Usage::
|
||||
|
||||
from cua_auto.terminal import terminal
|
||||
|
||||
output = []
|
||||
session = terminal.create("bash", on_data=lambda d: output.append(d))
|
||||
terminal.send_stdin(session.pid, b"echo hello\\n")
|
||||
terminal.send_stdin(session.pid, b"exit\\n")
|
||||
terminal.wait(session.pid)
|
||||
print(b"".join(output))
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class PtySession:
|
||||
"""Lightweight handle returned from :meth:`Terminal.create`."""
|
||||
|
||||
pid: int
|
||||
cols: int
|
||||
rows: int
|
||||
|
||||
|
||||
class _PtyProcess:
|
||||
"""Internal state holder for one PTY session."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.process = None # subprocess.Popen (Unix) or None (Windows)
|
||||
self.master_fd: Optional[int] = None # Unix master side of the PTY
|
||||
self.winpty_pty = None # winpty.PtyProcess (Windows)
|
||||
self.on_data: List[Callable[[bytes], None]] = []
|
||||
self.reader_thread: Optional[threading.Thread] = None
|
||||
self._exit_event: threading.Event = threading.Event()
|
||||
self.exit_code: Optional[int] = None
|
||||
|
||||
|
||||
class Terminal:
|
||||
"""Cross-platform PTY manager.
|
||||
|
||||
Each session is keyed by its process PID. All public methods are
|
||||
thread-safe.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._sessions: Dict[int, _PtyProcess] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create(
|
||||
self,
|
||||
command: Optional[str] = None,
|
||||
cols: int = 80,
|
||||
rows: int = 24,
|
||||
on_data: Optional[Callable[[bytes], None]] = None,
|
||||
cwd: Optional[str] = None,
|
||||
envs: Optional[Dict[str, str]] = None,
|
||||
) -> PtySession:
|
||||
"""Spawn a new PTY session.
|
||||
|
||||
Args:
|
||||
command: Shell command to run. Defaults to ``bash`` on Unix and
|
||||
``powershell`` on Windows.
|
||||
cols: Initial terminal width (columns).
|
||||
rows: Initial terminal height (rows).
|
||||
on_data: Callback invoked from the reader thread with raw bytes
|
||||
whenever the process writes output.
|
||||
cwd: Working directory for the spawned process.
|
||||
envs: Additional environment variables (merged into ``os.environ``).
|
||||
|
||||
Returns:
|
||||
:class:`PtySession` with the PID, cols, and rows.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
return self._create_windows(command, cols, rows, on_data, cwd, envs)
|
||||
return self._create_unix(command, cols, rows, on_data, cwd, envs)
|
||||
|
||||
def send_stdin(self, pid: int, data: bytes) -> None:
|
||||
"""Write *data* to the stdin of session *pid*."""
|
||||
with self._lock:
|
||||
holder = self._sessions.get(pid)
|
||||
if holder is None:
|
||||
raise KeyError(f"No PTY session with pid {pid}")
|
||||
if holder.master_fd is not None:
|
||||
try:
|
||||
os.write(holder.master_fd, data)
|
||||
except OSError:
|
||||
pass
|
||||
elif holder.winpty_pty is not None:
|
||||
holder.winpty_pty.write(data.decode("utf-8", errors="replace"))
|
||||
|
||||
def resize(self, pid: int, cols: int, rows: int) -> None:
|
||||
"""Resize the terminal window for session *pid*."""
|
||||
with self._lock:
|
||||
holder = self._sessions.get(pid)
|
||||
if holder is None:
|
||||
return
|
||||
if holder.master_fd is not None:
|
||||
import fcntl
|
||||
import struct
|
||||
import termios
|
||||
|
||||
try:
|
||||
fcntl.ioctl(
|
||||
holder.master_fd,
|
||||
termios.TIOCSWINSZ,
|
||||
struct.pack("HHHH", rows, cols, 0, 0),
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
elif holder.winpty_pty is not None:
|
||||
holder.winpty_pty.setwinsize(rows, cols)
|
||||
|
||||
def kill(self, pid: int) -> bool:
|
||||
"""Kill the process for session *pid*.
|
||||
|
||||
Returns:
|
||||
``True`` if the signal was delivered successfully.
|
||||
"""
|
||||
with self._lock:
|
||||
holder = self._sessions.get(pid)
|
||||
if holder is None:
|
||||
return False
|
||||
if holder.process is not None:
|
||||
try:
|
||||
holder.process.kill()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
if holder.winpty_pty is not None:
|
||||
try:
|
||||
holder.winpty_pty.terminate()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
def wait(self, pid: int, timeout: Optional[float] = None) -> Optional[int]:
|
||||
"""Block until session *pid* exits and return its exit code.
|
||||
|
||||
Args:
|
||||
pid: Session PID.
|
||||
timeout: Maximum seconds to wait. ``None`` means wait forever.
|
||||
|
||||
Returns:
|
||||
Exit code, or ``None`` if the timeout expired or pid is unknown.
|
||||
"""
|
||||
with self._lock:
|
||||
holder = self._sessions.get(pid)
|
||||
if holder is None:
|
||||
return None
|
||||
holder._exit_event.wait(timeout=timeout)
|
||||
return holder.exit_code
|
||||
|
||||
def connect(
|
||||
self,
|
||||
pid: int,
|
||||
on_data: Callable[[bytes], None],
|
||||
) -> PtySession:
|
||||
"""Attach a new *on_data* callback to an existing session.
|
||||
|
||||
The previous callbacks are replaced. Useful for reconnecting an SSE
|
||||
or WebSocket consumer to an already-running session.
|
||||
|
||||
Returns:
|
||||
:class:`PtySession` for the existing session (cols/rows default to
|
||||
80×24 since we don't track them after creation).
|
||||
"""
|
||||
with self._lock:
|
||||
holder = self._sessions.get(pid)
|
||||
if holder is None:
|
||||
raise KeyError(f"No PTY session with pid {pid}")
|
||||
with self._lock:
|
||||
holder.on_data = [on_data]
|
||||
return PtySession(pid=pid, cols=80, rows=24)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Platform-specific internals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _create_unix(
|
||||
self,
|
||||
command: Optional[str],
|
||||
cols: int,
|
||||
rows: int,
|
||||
on_data: Optional[Callable[[bytes], None]],
|
||||
cwd: Optional[str],
|
||||
envs: Optional[Dict[str, str]],
|
||||
) -> PtySession:
|
||||
import fcntl
|
||||
import pty as _pty
|
||||
import struct
|
||||
import subprocess
|
||||
import termios
|
||||
|
||||
cmd_str = command or "bash"
|
||||
if isinstance(cmd_str, str):
|
||||
cmd = ["/bin/sh", "-c", cmd_str]
|
||||
else:
|
||||
cmd = cmd_str
|
||||
|
||||
master_fd, slave_fd = _pty.openpty()
|
||||
|
||||
# Set initial terminal size
|
||||
fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
||||
|
||||
env = os.environ.copy()
|
||||
if envs:
|
||||
env.update(envs)
|
||||
env.setdefault("TERM", "xterm-256color")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
close_fds=True,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
|
||||
holder = _PtyProcess()
|
||||
holder.process = proc
|
||||
holder.master_fd = master_fd
|
||||
if on_data is not None:
|
||||
holder.on_data.append(on_data)
|
||||
|
||||
pid = proc.pid
|
||||
with self._lock:
|
||||
self._sessions[pid] = holder
|
||||
|
||||
def _reader() -> None:
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
data = os.read(master_fd, 4096)
|
||||
except OSError:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
with self._lock:
|
||||
callbacks = list(holder.on_data)
|
||||
for cb in callbacks:
|
||||
try:
|
||||
cb(data)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
with self._lock:
|
||||
holder.master_fd = None
|
||||
holder.exit_code = proc.wait()
|
||||
holder._exit_event.set()
|
||||
|
||||
holder.reader_thread = threading.Thread(
|
||||
target=_reader, daemon=True, name=f"pty-reader-{pid}"
|
||||
)
|
||||
holder.reader_thread.start()
|
||||
|
||||
return PtySession(pid=pid, cols=cols, rows=rows)
|
||||
|
||||
def _create_windows(
|
||||
self,
|
||||
command: Optional[str],
|
||||
cols: int,
|
||||
rows: int,
|
||||
on_data: Optional[Callable[[bytes], None]],
|
||||
cwd: Optional[str],
|
||||
envs: Optional[Dict[str, str]],
|
||||
) -> PtySession:
|
||||
try:
|
||||
import winpty # type: ignore[import]
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"pywinpty is required for PTY on Windows. " "Install with: pip install pywinpty"
|
||||
) from exc
|
||||
|
||||
cmd = command or "powershell"
|
||||
|
||||
env = None
|
||||
if envs:
|
||||
env = os.environ.copy()
|
||||
env.update(envs)
|
||||
|
||||
pty_proc = winpty.PtyProcess.spawn(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
dimensions=(rows, cols),
|
||||
)
|
||||
|
||||
holder = _PtyProcess()
|
||||
holder.winpty_pty = pty_proc
|
||||
if on_data is not None:
|
||||
holder.on_data.append(on_data)
|
||||
|
||||
pid: int = pty_proc.pid
|
||||
with self._lock:
|
||||
self._sessions[pid] = holder
|
||||
|
||||
def _reader() -> None:
|
||||
try:
|
||||
while pty_proc.isalive():
|
||||
try:
|
||||
data = pty_proc.read(4096)
|
||||
if data:
|
||||
raw: bytes = (
|
||||
data.encode("utf-8", errors="replace")
|
||||
if isinstance(data, str)
|
||||
else data
|
||||
)
|
||||
with self._lock:
|
||||
callbacks = list(holder.on_data)
|
||||
for cb in callbacks:
|
||||
try:
|
||||
cb(raw)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
break
|
||||
finally:
|
||||
try:
|
||||
holder.exit_code = pty_proc.wait()
|
||||
except Exception:
|
||||
holder.exit_code = -1
|
||||
holder._exit_event.set()
|
||||
|
||||
holder.reader_thread = threading.Thread(
|
||||
target=_reader, daemon=True, name=f"pty-reader-{pid}"
|
||||
)
|
||||
holder.reader_thread.start()
|
||||
|
||||
return PtySession(pid=pid, cols=cols, rows=rows)
|
||||
|
||||
|
||||
# Module-level singleton for convenience
|
||||
terminal = Terminal()
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Cross-platform window management via pywinctl."""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import webbrowser
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
try:
|
||||
import pywinctl as _pwc # type: ignore[import-untyped]
|
||||
except Exception:
|
||||
_pwc = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def _require_pwc() -> Any:
|
||||
if _pwc is None:
|
||||
raise RuntimeError("pywinctl is not available. Install it with: pip install pywinctl")
|
||||
return _pwc
|
||||
|
||||
|
||||
def _get_by_handle(handle: Any) -> Optional[Any]:
|
||||
"""Find a pywinctl window object by its native handle."""
|
||||
pwc = _require_pwc()
|
||||
try:
|
||||
for w in pwc.getAllWindows():
|
||||
if str(w.getHandle()) == str(handle):
|
||||
return w
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ── Active window ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_active_window() -> Optional[Any]:
|
||||
"""Return the pywinctl window object for the currently focused window."""
|
||||
pwc = _require_pwc()
|
||||
return pwc.getActiveWindow()
|
||||
|
||||
|
||||
def get_active_window_title() -> str:
|
||||
"""Return the title of the currently focused window, or 'Desktop'."""
|
||||
try:
|
||||
win = get_active_window()
|
||||
if win:
|
||||
return win.title or "Desktop"
|
||||
except Exception:
|
||||
pass
|
||||
return "Desktop"
|
||||
|
||||
|
||||
def get_active_window_handle() -> Optional[str]:
|
||||
"""Return the native handle of the active window as a string, or None."""
|
||||
try:
|
||||
win = get_active_window()
|
||||
if win:
|
||||
return str(win.getHandle())
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ── Window lookup ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_windows_with_title(title: str) -> List[str]:
|
||||
"""Return a list of native handle strings for windows whose title contains *title*."""
|
||||
pwc = _require_pwc()
|
||||
try:
|
||||
wins = pwc.getWindowsWithTitle(title, condition=pwc.Re.CONTAINS, flags=pwc.Re.IGNORECASE)
|
||||
return [str(w.getHandle()) for w in wins]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_window_name(handle: Any) -> Optional[str]:
|
||||
"""Return the title of the window with the given handle, or None."""
|
||||
w = _get_by_handle(handle)
|
||||
return w.title if w else None
|
||||
|
||||
|
||||
def get_window_size(handle: Any) -> Optional[Tuple[int, int]]:
|
||||
"""Return (width, height) of the window, or None if not found."""
|
||||
w = _get_by_handle(handle)
|
||||
if not w:
|
||||
return None
|
||||
width, height = w.size
|
||||
return int(width), int(height)
|
||||
|
||||
|
||||
def get_window_position(handle: Any) -> Optional[Tuple[int, int]]:
|
||||
"""Return (x, y) position of the window, or None if not found."""
|
||||
w = _get_by_handle(handle)
|
||||
if not w:
|
||||
return None
|
||||
x, y = w.position
|
||||
return int(x), int(y)
|
||||
|
||||
|
||||
# ── Window actions ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def activate_window(handle: Any) -> bool:
|
||||
"""Bring the window to the foreground and give it focus."""
|
||||
w = _get_by_handle(handle)
|
||||
if not w:
|
||||
return False
|
||||
return bool(w.activate())
|
||||
|
||||
|
||||
def minimize_window(handle: Any) -> bool:
|
||||
"""Minimize the window."""
|
||||
w = _get_by_handle(handle)
|
||||
if not w:
|
||||
return False
|
||||
return bool(w.minimize())
|
||||
|
||||
|
||||
def maximize_window(handle: Any) -> bool:
|
||||
"""Maximize the window."""
|
||||
w = _get_by_handle(handle)
|
||||
if not w:
|
||||
return False
|
||||
return bool(w.maximize())
|
||||
|
||||
|
||||
def close_window(handle: Any) -> bool:
|
||||
"""Close the window."""
|
||||
w = _get_by_handle(handle)
|
||||
if not w:
|
||||
return False
|
||||
return bool(w.close())
|
||||
|
||||
|
||||
def set_window_size(handle: Any, width: int, height: int) -> bool:
|
||||
"""Resize the window to (width, height)."""
|
||||
w = _get_by_handle(handle)
|
||||
if not w:
|
||||
return False
|
||||
return bool(w.resizeTo(int(width), int(height)))
|
||||
|
||||
|
||||
def set_window_position(handle: Any, x: int, y: int) -> bool:
|
||||
"""Move the window to (x, y)."""
|
||||
w = _get_by_handle(handle)
|
||||
if not w:
|
||||
return False
|
||||
return bool(w.moveTo(int(x), int(y)))
|
||||
|
||||
|
||||
# ── Open / launch ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def open(target: str) -> bool: # noqa: A001
|
||||
"""Open a URL or file path with the default application.
|
||||
|
||||
URLs are opened in the default browser. Files are opened with the OS
|
||||
default handler (``open`` on macOS, ``xdg-open`` on Linux,
|
||||
``os.startfile`` on Windows).
|
||||
"""
|
||||
if target.startswith("http://") or target.startswith("https://"):
|
||||
return bool(webbrowser.open(target))
|
||||
|
||||
sys = platform.system().lower()
|
||||
if sys == "darwin":
|
||||
subprocess.Popen(["open", target])
|
||||
elif sys == "linux":
|
||||
subprocess.Popen(["xdg-open", target])
|
||||
elif sys == "windows":
|
||||
os.startfile(target) # type: ignore[attr-defined]
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported OS: {sys}")
|
||||
return True
|
||||
|
||||
|
||||
def launch(app: str, args: Optional[List[str]] = None) -> int:
|
||||
"""Launch an application and return its PID.
|
||||
|
||||
If *args* is given, the app is launched with those arguments.
|
||||
Otherwise the command string is passed to the system shell, allowing
|
||||
strings like ``"libreoffice --writer"``.
|
||||
"""
|
||||
if args:
|
||||
proc = subprocess.Popen([app, *args])
|
||||
else:
|
||||
proc = subprocess.Popen(app, shell=True)
|
||||
return proc.pid
|
||||
@@ -0,0 +1,95 @@
|
||||
[project]
|
||||
name = "cua-auto"
|
||||
version = "0.1.2"
|
||||
description = "Cross-platform automation library — mouse, keyboard, screen, window, clipboard, shell"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
authors = [
|
||||
{ name = "TryCua", email = "hello@trycua.com" }
|
||||
]
|
||||
keywords = [
|
||||
"automation",
|
||||
"mouse",
|
||||
"keyboard",
|
||||
"screenshot",
|
||||
"window",
|
||||
"computer-use",
|
||||
"pyautogui",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Software Development :: Libraries",
|
||||
]
|
||||
requires-python = ">=3.11,<3.14"
|
||||
|
||||
dependencies = [
|
||||
# Mouse + keyboard control (cross-platform)
|
||||
"pynput>=1.7.0",
|
||||
# Screenshot + image processing
|
||||
"pillow>=10.0.0",
|
||||
# Clipboard access (cross-platform)
|
||||
"pyperclip>=1.9.0",
|
||||
# Window management (cross-platform)
|
||||
"pywinctl>=0.4",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Faster multi-monitor screenshot backend
|
||||
mss = [
|
||||
"mss>=9.0.0",
|
||||
]
|
||||
# Windows-specific extras (cursor position via win32, etc.)
|
||||
windows = [
|
||||
"pywin32>=306; sys_platform == 'win32'",
|
||||
]
|
||||
# PTY support
|
||||
pty = [
|
||||
"pywinpty>=2.0.0; sys_platform == 'win32'",
|
||||
]
|
||||
all = [
|
||||
"cua-auto[mss,windows,pty]",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/trycua/cua"
|
||||
Documentation = "https://docs.trycua.com"
|
||||
Repository = "https://github.com/trycua/cua"
|
||||
Issues = "https://github.com/trycua/cua/issues"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build]
|
||||
include = [
|
||||
"cua_auto/**",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
]
|
||||
exclude = [
|
||||
"cua_auto/**/__pycache__",
|
||||
"**/.DS_Store",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["cua_auto"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W"]
|
||||
ignore = ["E501"]
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Unit tests for cua_auto.terminal — cross-platform PTY engine.
|
||||
|
||||
Covers Unix PTY paths (echo, spaces, stdin interaction, exit codes, kill, resize).
|
||||
Windows-only paths are skipped when not running on win32 (and vice-versa).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
# All tests in this module require a real PTY, so skip on platforms where we
|
||||
# can't spawn one without the optional dependency.
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="Unix PTY tests — win32 uses pywinpty (tested separately)",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_command(cmd: str, timeout: float = 3.0) -> tuple[bytes, int]:
|
||||
"""Spawn *cmd* in a PTY, collect all output, return (output, exit_code)."""
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
chunks: list[bytes] = []
|
||||
t = Terminal()
|
||||
session = t.create(command=cmd, cols=80, rows=24, on_data=chunks.append)
|
||||
exit_code = t.wait(session.pid, timeout=timeout)
|
||||
return b"".join(chunks), exit_code or 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import / singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTerminalImport:
|
||||
def test_module_importable(self):
|
||||
import cua_auto.terminal # noqa: F401
|
||||
|
||||
def test_singleton_exists(self):
|
||||
from cua_auto.terminal import Terminal, terminal
|
||||
|
||||
assert isinstance(terminal, Terminal)
|
||||
|
||||
def test_pty_session_dataclass(self):
|
||||
from cua_auto.terminal import PtySession
|
||||
|
||||
s = PtySession(pid=1234, cols=80, rows=24)
|
||||
assert s.pid == 1234
|
||||
assert s.cols == 80
|
||||
assert s.rows == 24
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basic echo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEchoBasic:
|
||||
def test_echo_hello(self):
|
||||
output, code = _run_command("echo hello")
|
||||
assert b"hello" in output
|
||||
assert code == 0
|
||||
|
||||
def test_echo_with_spaces(self):
|
||||
output, code = _run_command("echo hello world")
|
||||
assert b"hello world" in output
|
||||
assert code == 0
|
||||
|
||||
def test_echo_leading_trailing_spaces(self):
|
||||
output, code = _run_command("echo ' spaced '")
|
||||
assert b"spaced" in output
|
||||
assert code == 0
|
||||
|
||||
def test_echo_multiple_words(self):
|
||||
output, code = _run_command("echo foo bar baz")
|
||||
assert b"foo" in output
|
||||
assert b"bar" in output
|
||||
assert b"baz" in output
|
||||
assert code == 0
|
||||
|
||||
def test_echo_empty_string(self):
|
||||
# echo with an empty-ish arg should still exit 0
|
||||
output, code = _run_command("echo ''")
|
||||
assert code == 0
|
||||
|
||||
def test_echo_numbers(self):
|
||||
output, code = _run_command("echo 42")
|
||||
assert b"42" in output
|
||||
assert code == 0
|
||||
|
||||
def test_echo_special_chars(self):
|
||||
output, code = _run_command("echo 'hello-world_test'")
|
||||
assert b"hello-world_test" in output
|
||||
assert code == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exit codes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExitCodes:
|
||||
def test_exit_zero(self):
|
||||
_, code = _run_command("exit 0")
|
||||
assert code == 0
|
||||
|
||||
def test_exit_nonzero(self):
|
||||
_, code = _run_command("exit 1")
|
||||
assert code == 1
|
||||
|
||||
def test_exit_42(self):
|
||||
_, code = _run_command("exit 42")
|
||||
assert code == 42
|
||||
|
||||
def test_true_command(self):
|
||||
_, code = _run_command("true")
|
||||
assert code == 0
|
||||
|
||||
def test_false_command(self):
|
||||
_, code = _run_command("false")
|
||||
assert code != 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive stdin (send_stdin)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSendStdin:
|
||||
def test_send_echo_via_stdin(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
chunks: list[bytes] = []
|
||||
t = Terminal()
|
||||
session = t.create(command="bash", cols=80, rows=24, on_data=chunks.append)
|
||||
|
||||
# Give bash a moment to start
|
||||
time.sleep(0.2)
|
||||
t.send_stdin(session.pid, b"echo hello_from_stdin\n")
|
||||
t.send_stdin(session.pid, b"exit\n")
|
||||
t.wait(session.pid, timeout=3.0)
|
||||
|
||||
output = b"".join(chunks)
|
||||
assert b"hello_from_stdin" in output
|
||||
|
||||
def test_send_echo_with_spaces_via_stdin(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
chunks: list[bytes] = []
|
||||
t = Terminal()
|
||||
session = t.create(command="bash", cols=80, rows=24, on_data=chunks.append)
|
||||
|
||||
time.sleep(0.2)
|
||||
t.send_stdin(session.pid, b"echo 'hello world from stdin'\n")
|
||||
t.send_stdin(session.pid, b"exit\n")
|
||||
t.wait(session.pid, timeout=3.0)
|
||||
|
||||
output = b"".join(chunks)
|
||||
assert b"hello world from stdin" in output
|
||||
|
||||
def test_send_multiple_commands(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
chunks: list[bytes] = []
|
||||
t = Terminal()
|
||||
session = t.create(command="bash", cols=80, rows=24, on_data=chunks.append)
|
||||
|
||||
time.sleep(0.2)
|
||||
t.send_stdin(session.pid, b"echo first\n")
|
||||
t.send_stdin(session.pid, b"echo second\n")
|
||||
t.send_stdin(session.pid, b"exit\n")
|
||||
t.wait(session.pid, timeout=3.0)
|
||||
|
||||
output = b"".join(chunks)
|
||||
assert b"first" in output
|
||||
assert b"second" in output
|
||||
|
||||
def test_exit_code_via_stdin(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
t = Terminal()
|
||||
session = t.create(command="bash", cols=80, rows=24, on_data=lambda _: None)
|
||||
|
||||
time.sleep(0.2)
|
||||
t.send_stdin(session.pid, b"exit 7\n")
|
||||
code = t.wait(session.pid, timeout=3.0)
|
||||
assert code == 7
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kill
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestKill:
|
||||
def test_kill_running_session(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
t = Terminal()
|
||||
# sleep for a long time — we'll kill it
|
||||
session = t.create(command="sleep 60", cols=80, rows=24, on_data=lambda _: None)
|
||||
|
||||
killed = t.kill(session.pid)
|
||||
assert killed is True
|
||||
|
||||
# After kill, wait should return quickly
|
||||
code = t.wait(session.pid, timeout=2.0)
|
||||
assert code is not None # process has exited
|
||||
|
||||
def test_kill_unknown_pid_returns_false(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
t = Terminal()
|
||||
assert t.kill(9999999) is False
|
||||
|
||||
def test_wait_unknown_pid_returns_none(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
t = Terminal()
|
||||
assert t.wait(9999999, timeout=0.1) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resize
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResize:
|
||||
def test_resize_does_not_raise(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
t = Terminal()
|
||||
session = t.create(command="bash", cols=80, rows=24, on_data=lambda _: None)
|
||||
|
||||
# Resize should not raise
|
||||
t.resize(session.pid, 120, 40)
|
||||
t.resize(session.pid, 80, 24)
|
||||
|
||||
t.send_stdin(session.pid, b"exit\n")
|
||||
t.wait(session.pid, timeout=2.0)
|
||||
|
||||
def test_resize_unknown_pid_is_noop(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
t = Terminal()
|
||||
# Should not raise even for unknown pids
|
||||
t.resize(9999999, 80, 24)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connect (callback replacement)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConnect:
|
||||
def test_connect_replaces_callback(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
first_chunks: list[bytes] = []
|
||||
second_chunks: list[bytes] = []
|
||||
|
||||
t = Terminal()
|
||||
session = t.create(command="bash", cols=80, rows=24, on_data=first_chunks.append)
|
||||
|
||||
time.sleep(0.2)
|
||||
t.send_stdin(session.pid, b"echo before_connect\n")
|
||||
time.sleep(0.1)
|
||||
|
||||
# Re-attach with a new callback
|
||||
t.connect(session.pid, second_chunks.append)
|
||||
|
||||
t.send_stdin(session.pid, b"echo after_connect\n")
|
||||
t.send_stdin(session.pid, b"exit\n")
|
||||
t.wait(session.pid, timeout=3.0)
|
||||
|
||||
# "after_connect" should appear in the second callback's data
|
||||
assert b"after_connect" in b"".join(second_chunks)
|
||||
|
||||
def test_connect_unknown_pid_raises(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
t = Terminal()
|
||||
with pytest.raises(KeyError):
|
||||
t.connect(9999999, lambda _: None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton convenience
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSingletonTerminal:
|
||||
def test_singleton_echo(self):
|
||||
from cua_auto.terminal import terminal
|
||||
|
||||
chunks: list[bytes] = []
|
||||
session = terminal.create(
|
||||
command="echo singleton_works",
|
||||
cols=80,
|
||||
rows=24,
|
||||
on_data=chunks.append,
|
||||
)
|
||||
terminal.wait(session.pid, timeout=3.0)
|
||||
assert b"singleton_works" in b"".join(chunks)
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Windows-specific PTY tests for cua_auto.terminal.
|
||||
|
||||
Uses pywinpty under the hood via the Terminal._create_windows path.
|
||||
All tests are skipped on non-Windows platforms.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.platform != "win32",
|
||||
reason="Windows PTY tests — requires pywinpty",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_command(cmd: str, timeout: float = 10.0) -> tuple[bytes, int]:
|
||||
"""Spawn *cmd* via PowerShell in a PTY, collect output, return (output, exit_code)."""
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
chunks: list[bytes] = []
|
||||
t = Terminal()
|
||||
# Wrap in powershell -Command so we can use PS syntax
|
||||
full_cmd = f'powershell -NoProfile -NonInteractive -Command "{cmd}"'
|
||||
session = t.create(command=full_cmd, cols=80, rows=24, on_data=chunks.append)
|
||||
exit_code = t.wait(session.pid, timeout=timeout)
|
||||
return b"".join(chunks), exit_code or 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import / singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTerminalImport:
|
||||
def test_module_importable(self):
|
||||
import cua_auto.terminal # noqa: F401
|
||||
|
||||
def test_singleton_exists(self):
|
||||
from cua_auto.terminal import Terminal, terminal
|
||||
|
||||
assert isinstance(terminal, Terminal)
|
||||
|
||||
def test_pty_session_dataclass(self):
|
||||
from cua_auto.terminal import PtySession
|
||||
|
||||
s = PtySession(pid=1234, cols=80, rows=24)
|
||||
assert s.pid == 1234
|
||||
assert s.cols == 80
|
||||
assert s.rows == 24
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basic echo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEchoBasic:
|
||||
def test_echo_hello(self):
|
||||
output, code = _run_command("Write-Output hello")
|
||||
assert b"hello" in output
|
||||
assert code == 0
|
||||
|
||||
def test_echo_with_spaces(self):
|
||||
output, code = _run_command("Write-Output 'hello world'")
|
||||
assert b"hello world" in output
|
||||
assert code == 0
|
||||
|
||||
def test_echo_numbers(self):
|
||||
output, code = _run_command("Write-Output 42")
|
||||
assert b"42" in output
|
||||
assert code == 0
|
||||
|
||||
def test_echo_multiple_words(self):
|
||||
output, code = _run_command("Write-Output 'foo bar baz'")
|
||||
assert b"foo" in output
|
||||
assert b"bar" in output
|
||||
assert b"baz" in output
|
||||
assert code == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exit codes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExitCodes:
|
||||
def test_exit_zero(self):
|
||||
_, code = _run_command("exit 0")
|
||||
assert code == 0
|
||||
|
||||
def test_exit_nonzero(self):
|
||||
# pywinpty/ConPTY does not propagate PowerShell's exit code reliably;
|
||||
# cmd.exe /c exit N does propagate correctly.
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
t = Terminal()
|
||||
session = t.create(command="cmd /c exit 1", cols=80, rows=24, on_data=lambda _: None)
|
||||
code = t.wait(session.pid, timeout=5.0)
|
||||
assert code == 1
|
||||
|
||||
def test_true_command(self):
|
||||
_, code = _run_command("$true | Out-Null")
|
||||
assert code == 0
|
||||
|
||||
def test_false_exits_nonzero(self):
|
||||
# pywinpty/ConPTY does not propagate PowerShell's exit code reliably;
|
||||
# cmd.exe /c exit N does propagate correctly.
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
t = Terminal()
|
||||
session = t.create(command="cmd /c exit 42", cols=80, rows=24, on_data=lambda _: None)
|
||||
code = t.wait(session.pid, timeout=5.0)
|
||||
assert code == 42
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive stdin (send_stdin)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSendStdin:
|
||||
def test_send_echo_via_stdin(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
chunks: list[bytes] = []
|
||||
t = Terminal()
|
||||
session = t.create(
|
||||
command="powershell -NoProfile -NonInteractive",
|
||||
cols=80,
|
||||
rows=24,
|
||||
on_data=chunks.append,
|
||||
)
|
||||
|
||||
time.sleep(0.5)
|
||||
t.send_stdin(session.pid, b"Write-Output hello_from_stdin\r\n")
|
||||
t.send_stdin(session.pid, b"exit\r\n")
|
||||
t.wait(session.pid, timeout=10.0)
|
||||
|
||||
output = b"".join(chunks)
|
||||
assert b"hello_from_stdin" in output
|
||||
|
||||
def test_send_multiple_commands(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
chunks: list[bytes] = []
|
||||
t = Terminal()
|
||||
session = t.create(
|
||||
command="powershell -NoProfile -NonInteractive",
|
||||
cols=80,
|
||||
rows=24,
|
||||
on_data=chunks.append,
|
||||
)
|
||||
|
||||
time.sleep(0.5)
|
||||
t.send_stdin(session.pid, b"Write-Output first\r\n")
|
||||
t.send_stdin(session.pid, b"Write-Output second\r\n")
|
||||
t.send_stdin(session.pid, b"exit\r\n")
|
||||
t.wait(session.pid, timeout=10.0)
|
||||
|
||||
output = b"".join(chunks)
|
||||
assert b"first" in output
|
||||
assert b"second" in output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kill
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestKill:
|
||||
def test_kill_running_session(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
t = Terminal()
|
||||
session = t.create(
|
||||
command="powershell -NoProfile -NonInteractive -Command Start-Sleep 60",
|
||||
cols=80,
|
||||
rows=24,
|
||||
on_data=lambda _: None,
|
||||
)
|
||||
|
||||
time.sleep(0.3)
|
||||
killed = t.kill(session.pid)
|
||||
assert killed is True
|
||||
|
||||
code = t.wait(session.pid, timeout=5.0)
|
||||
assert code is not None
|
||||
|
||||
def test_kill_unknown_pid_returns_false(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
t = Terminal()
|
||||
assert t.kill(9999999) is False
|
||||
|
||||
def test_wait_unknown_pid_returns_none(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
t = Terminal()
|
||||
assert t.wait(9999999, timeout=0.1) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resize
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResize:
|
||||
def test_resize_does_not_raise(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
t = Terminal()
|
||||
session = t.create(
|
||||
command="powershell -NoProfile -NonInteractive",
|
||||
cols=80,
|
||||
rows=24,
|
||||
on_data=lambda _: None,
|
||||
)
|
||||
|
||||
time.sleep(0.3)
|
||||
t.resize(session.pid, 120, 40)
|
||||
t.resize(session.pid, 80, 24)
|
||||
|
||||
t.send_stdin(session.pid, b"exit\r\n")
|
||||
t.wait(session.pid, timeout=5.0)
|
||||
|
||||
def test_resize_unknown_pid_is_noop(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
t = Terminal()
|
||||
t.resize(9999999, 80, 24) # should not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connect (callback replacement)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConnect:
|
||||
def test_connect_replaces_callback(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
first_chunks: list[bytes] = []
|
||||
second_chunks: list[bytes] = []
|
||||
|
||||
t = Terminal()
|
||||
session = t.create(
|
||||
command="powershell -NoProfile -NonInteractive",
|
||||
cols=80,
|
||||
rows=24,
|
||||
on_data=first_chunks.append,
|
||||
)
|
||||
|
||||
time.sleep(0.5)
|
||||
t.send_stdin(session.pid, b"Write-Output before_connect\r\n")
|
||||
time.sleep(0.3)
|
||||
|
||||
t.connect(session.pid, second_chunks.append)
|
||||
|
||||
t.send_stdin(session.pid, b"Write-Output after_connect\r\n")
|
||||
t.send_stdin(session.pid, b"exit\r\n")
|
||||
t.wait(session.pid, timeout=10.0)
|
||||
|
||||
assert b"after_connect" in b"".join(second_chunks)
|
||||
|
||||
def test_connect_unknown_pid_raises(self):
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
t = Terminal()
|
||||
with pytest.raises(KeyError):
|
||||
t.connect(9999999, lambda _: None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton convenience
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSingletonTerminal:
|
||||
def test_singleton_echo(self):
|
||||
from cua_auto.terminal import terminal
|
||||
|
||||
chunks: list[bytes] = []
|
||||
session = terminal.create(
|
||||
command='powershell -NoProfile -NonInteractive -Command "Write-Output singleton_works"',
|
||||
cols=80,
|
||||
rows=24,
|
||||
on_data=chunks.append,
|
||||
)
|
||||
terminal.wait(session.pid, timeout=10.0)
|
||||
assert b"singleton_works" in b"".join(chunks)
|
||||
Reference in New Issue
Block a user