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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:03:19 +08:00
commit 91e75e620b
3227 changed files with 1307078 additions and 0 deletions
+14
View File
@@ -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}"
+62
View File
@@ -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)
```
+34
View File
@@ -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)
+160
View File
@@ -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)
+138
View File
@@ -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)
+148
View File
@@ -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)
+53
View File
@@ -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,
)
+355
View File
@@ -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()
+188
View File
@@ -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
+95
View File
@@ -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"]
+312
View File
@@ -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)