use-computer-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ """use-computer: execute input on a screen for computer-use agents.
2
+
3
+ The acting half of a pair. ui-locator answers where; this performs the action there.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from importlib.metadata import PackageNotFoundError, version
9
+
10
+ from use_computer.actions import (
11
+ Action,
12
+ ClickAction,
13
+ DoubleClickAction,
14
+ DragAction,
15
+ KeyAction,
16
+ MouseButton,
17
+ MoveAction,
18
+ RightClickAction,
19
+ ScreenshotAction,
20
+ ScrollAction,
21
+ ScrollDirection,
22
+ TypeAction,
23
+ )
24
+ from use_computer.backends import Backend, create_backend
25
+ from use_computer.compare import ChangeReport, Screenshot, compare
26
+ from use_computer.config import BackendProfile, ResolvedConfig, Settings
27
+ from use_computer.config import load as load_config
28
+ from use_computer.coordinates import Coordinate, CoordinateSpace, ScreenInfo, convert
29
+ from use_computer.errors import (
30
+ ActionFailedError,
31
+ BackendNotAvailableError,
32
+ ConfigError,
33
+ CoordinateSpaceError,
34
+ KeySyntaxError,
35
+ PermissionDeniedError,
36
+ UseComputerError,
37
+ )
38
+ from use_computer.keys import KeyCombo, parse_combo
39
+ from use_computer.runner import ActionResult, ErrorInfo, RunResult, Session, run_actions
40
+
41
+ #: The distribution name on PyPI, which differs from the import package: `use-computer` was
42
+ #: already taken there. importlib.metadata is keyed by the distribution, so this is the name
43
+ #: that must appear here.
44
+ DISTRIBUTION = "use-computer-cli"
45
+
46
+ try:
47
+ __version__ = version(DISTRIBUTION)
48
+ except PackageNotFoundError: # pragma: no cover - source checkout without an install
49
+ __version__ = "0.0.0"
50
+
51
+ __all__ = [
52
+ "DISTRIBUTION",
53
+ "Action",
54
+ "ActionFailedError",
55
+ "ActionResult",
56
+ "Backend",
57
+ "BackendNotAvailableError",
58
+ "BackendProfile",
59
+ "ChangeReport",
60
+ "ClickAction",
61
+ "ConfigError",
62
+ "Coordinate",
63
+ "CoordinateSpace",
64
+ "CoordinateSpaceError",
65
+ "DoubleClickAction",
66
+ "DragAction",
67
+ "ErrorInfo",
68
+ "KeyAction",
69
+ "KeyCombo",
70
+ "KeySyntaxError",
71
+ "MouseButton",
72
+ "MoveAction",
73
+ "PermissionDeniedError",
74
+ "ResolvedConfig",
75
+ "RightClickAction",
76
+ "RunResult",
77
+ "ScreenInfo",
78
+ "Screenshot",
79
+ "ScreenshotAction",
80
+ "ScrollAction",
81
+ "ScrollDirection",
82
+ "Session",
83
+ "Settings",
84
+ "TypeAction",
85
+ "UseComputerError",
86
+ "__version__",
87
+ "compare",
88
+ "convert",
89
+ "create_backend",
90
+ "load_config",
91
+ "parse_combo",
92
+ "run_actions",
93
+ ]
@@ -0,0 +1,184 @@
1
+ """The action models, and the resolution that happens before a backend sees them.
2
+
3
+ The action set is the whole surface of what use-computer does to a screen. Coordinate scaling
4
+ and key parsing are applied here, so that everything crossing the backend boundary is already
5
+ in actuation units and canonical key names.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import Enum
11
+ from pathlib import Path
12
+ from typing import Annotated, Literal
13
+
14
+ from pydantic import BaseModel, Field, TypeAdapter, field_validator
15
+
16
+ from use_computer.coordinates import Coordinate, CoordinateSpace, ScreenInfo, convert
17
+ from use_computer.keys import KeyCombo, canonical, parse_combo
18
+
19
+
20
+ class MouseButton(str, Enum):
21
+ LEFT = "left"
22
+ RIGHT = "right"
23
+ MIDDLE = "middle"
24
+
25
+
26
+ class ScrollDirection(str, Enum):
27
+ UP = "up"
28
+ DOWN = "down"
29
+ LEFT = "left"
30
+ RIGHT = "right"
31
+
32
+
33
+ class BaseAction(BaseModel):
34
+ """Fields every action shares."""
35
+
36
+ delay: float | None = Field(default=None, ge=0, description="Seconds to wait afterwards.")
37
+ verify: bool = Field(default=False, description="Compare the screen before and after.")
38
+
39
+ @property
40
+ def target(self) -> Coordinate | None:
41
+ """The point this action acts on, or None if it has no coordinate."""
42
+ return None
43
+
44
+ @property
45
+ def origin(self) -> Coordinate | None:
46
+ """The point this action starts from -- only a drag has one."""
47
+ return None
48
+
49
+
50
+ class _Positioned(BaseAction):
51
+ """An action that may carry a coordinate. Omitting it acts where the pointer already is."""
52
+
53
+ x: int | None = None
54
+ y: int | None = None
55
+ space: CoordinateSpace | None = None
56
+
57
+ @property
58
+ def target(self) -> Coordinate | None:
59
+ if self.x is None or self.y is None:
60
+ return None
61
+ space = self.space if self.space is not None else CoordinateSpace.SCREENSHOT
62
+ return Coordinate(x=self.x, y=self.y, space=space)
63
+
64
+
65
+ class MoveAction(_Positioned):
66
+ action: Literal["move"] = "move"
67
+ x: int
68
+ y: int
69
+
70
+
71
+ class ClickAction(_Positioned):
72
+ action: Literal["click"] = "click"
73
+ button: MouseButton = MouseButton.LEFT
74
+
75
+
76
+ class DoubleClickAction(_Positioned):
77
+ action: Literal["double_click"] = "double_click"
78
+ button: MouseButton = MouseButton.LEFT
79
+
80
+
81
+ class RightClickAction(_Positioned):
82
+ action: Literal["right_click"] = "right_click"
83
+
84
+
85
+ class DragAction(BaseAction):
86
+ action: Literal["drag"] = "drag"
87
+ from_x: int
88
+ from_y: int
89
+ to_x: int
90
+ to_y: int
91
+ space: CoordinateSpace | None = None
92
+ button: MouseButton = MouseButton.LEFT
93
+
94
+ @property
95
+ def origin(self) -> Coordinate:
96
+ return Coordinate(x=self.from_x, y=self.from_y, space=self._space)
97
+
98
+ @property
99
+ def target(self) -> Coordinate:
100
+ return Coordinate(x=self.to_x, y=self.to_y, space=self._space)
101
+
102
+ @property
103
+ def _space(self) -> CoordinateSpace:
104
+ return self.space if self.space is not None else CoordinateSpace.SCREENSHOT
105
+
106
+
107
+ class ScrollAction(_Positioned):
108
+ action: Literal["scroll"] = "scroll"
109
+ amount: int
110
+ direction: ScrollDirection = ScrollDirection.DOWN
111
+
112
+
113
+ class TypeAction(BaseAction):
114
+ action: Literal["type"] = "type"
115
+ text: str
116
+ rate: float | None = Field(
117
+ default=None,
118
+ ge=0,
119
+ description="Seconds between keystrokes; None uses the configured rate.",
120
+ )
121
+
122
+
123
+ class KeyAction(BaseAction):
124
+ action: Literal["key"] = "key"
125
+ combo: str
126
+
127
+ @field_validator("combo")
128
+ @classmethod
129
+ def _canonicalise(cls, value: str) -> str:
130
+ # Parse at construction: an unknown key name must fail before a connection is opened,
131
+ # and the canonical spelling is what appears in results and logs.
132
+ return canonical(value)
133
+
134
+ @property
135
+ def key_combo(self) -> KeyCombo:
136
+ return parse_combo(self.combo)
137
+
138
+
139
+ class ScreenshotAction(BaseAction):
140
+ action: Literal["screenshot"] = "screenshot"
141
+ out: Path | None = None
142
+ base64: bool = False
143
+
144
+
145
+ Action = Annotated[
146
+ MoveAction
147
+ | ClickAction
148
+ | DoubleClickAction
149
+ | RightClickAction
150
+ | DragAction
151
+ | ScrollAction
152
+ | TypeAction
153
+ | KeyAction
154
+ | ScreenshotAction,
155
+ Field(discriminator="action"),
156
+ ]
157
+
158
+ #: Parses a batch file: a JSON array of action objects, discriminated on `action`.
159
+ ActionListAdapter: TypeAdapter[list[Action]] = TypeAdapter(list[Action])
160
+
161
+ #: Parses a single action object.
162
+ ActionAdapter: TypeAdapter[Action] = TypeAdapter(Action)
163
+
164
+
165
+ def with_default_space(action: Action, space: CoordinateSpace) -> Action:
166
+ """Fill in the coordinate space an action did not state, from configuration."""
167
+ if getattr(action, "space", "missing") is None:
168
+ return action.model_copy(update={"space": space})
169
+ return action
170
+
171
+
172
+ def resolve(action: Action, screen: ScreenInfo) -> tuple[Coordinate | None, Coordinate | None]:
173
+ """Convert an action's coordinates into actuation units.
174
+
175
+ Returns ``(target, origin)``; either may be ``None`` when the action carries no coordinate.
176
+
177
+ Raises:
178
+ CoordinateSpaceError: when the scale needed for the conversion is unknown.
179
+ """
180
+ target = action.target
181
+ origin = action.origin
182
+ resolved_target = convert(target, CoordinateSpace.ACTUATION, screen) if target else None
183
+ resolved_origin = convert(origin, CoordinateSpace.ACTUATION, screen) if origin else None
184
+ return resolved_target, resolved_origin
@@ -0,0 +1,31 @@
1
+ """Backend selection.
2
+
3
+ Adding a backend is a module here plus a name in the config file -- never a change to the
4
+ action layer.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from use_computer.backends.base import Backend, BackendNotAvailableError, require
10
+ from use_computer.config import BackendProfile
11
+ from use_computer.errors import ConfigError
12
+
13
+ __all__ = ["Backend", "BackendNotAvailableError", "create_backend", "require"]
14
+
15
+
16
+ def create_backend(profile: BackendProfile) -> Backend:
17
+ """Construct the backend a profile names. The module is imported only when selected."""
18
+ if profile.backend == "local":
19
+ from use_computer.backends.local import LocalBackend
20
+
21
+ return LocalBackend(allow_local=profile.allow_local, scale=profile.scale)
22
+ if profile.backend == "vnc":
23
+ from use_computer.backends.vnc import VNCBackend
24
+
25
+ return VNCBackend(
26
+ host=profile.host,
27
+ port=profile.port,
28
+ password=profile.password,
29
+ scale=profile.scale,
30
+ )
31
+ raise ConfigError(f"unknown backend {profile.backend!r} in profile {profile.name!r}")
@@ -0,0 +1,66 @@
1
+ """The backend Protocol, and the lazy-import helper every backend constructs itself through.
2
+
3
+ Two interchangeable backends sit behind this interface. Coordinates crossing it are always in
4
+ *actuation* units -- conversion happens above. A backend's third-party dependency is imported
5
+ inside its constructor, never at module import, so the package installs and `--help` works with
6
+ no extras present.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from importlib import import_module
12
+ from types import ModuleType
13
+ from typing import Protocol, runtime_checkable
14
+
15
+ from use_computer.actions import MouseButton, ScrollDirection
16
+ from use_computer.compare import Screenshot
17
+ from use_computer.coordinates import ScreenInfo
18
+ from use_computer.errors import BackendNotAvailableError
19
+ from use_computer.keys import KeyCombo
20
+
21
+
22
+ @runtime_checkable
23
+ class Backend(Protocol):
24
+ """What every backend implements. The fake backend used in tests satisfies it too."""
25
+
26
+ name: str
27
+
28
+ def screen_info(self) -> ScreenInfo:
29
+ """Report this backend's own coordinate spaces and screen size."""
30
+
31
+ def screenshot(self) -> Screenshot:
32
+ """Capture the current screen."""
33
+
34
+ def move(self, x: int, y: int) -> None: ...
35
+
36
+ def click(self, x: int | None, y: int | None, button: MouseButton, count: int) -> None: ...
37
+
38
+ def drag(
39
+ self, from_x: int, from_y: int, to_x: int, to_y: int, button: MouseButton
40
+ ) -> None: ...
41
+
42
+ def scroll(
43
+ self, amount: int, direction: ScrollDirection, x: int | None, y: int | None
44
+ ) -> None: ...
45
+
46
+ def type_text(self, text: str, rate: float) -> None: ...
47
+
48
+ def key(self, combo: KeyCombo) -> None: ...
49
+
50
+ def close(self) -> None:
51
+ """Release whatever the backend holds. Called even when an action failed."""
52
+
53
+
54
+ def require(module: str, *, backend: str, extra: str) -> ModuleType:
55
+ """Import an optional dependency, or say exactly what to install.
56
+
57
+ Raises:
58
+ BackendNotAvailableError: naming the extra, not the import error.
59
+ """
60
+ try:
61
+ return import_module(module)
62
+ except ImportError as exc:
63
+ raise BackendNotAvailableError(backend=backend, extra=extra, missing=module) from exc
64
+
65
+
66
+ __all__ = ["Backend", "BackendNotAvailableError", "require"]
@@ -0,0 +1,214 @@
1
+ """The local backend: drives the display of the machine use-computer runs on.
2
+
3
+ Input via pynput, capture via mss. pyautogui is deliberately not used -- its last release,
4
+ 0.9.54, dates from 2023.
5
+
6
+ This backend types on the user's own keyboard and moves their own pointer, so it refuses to act
7
+ without an explicit opt-in, and it checks the macOS permissions that would otherwise make every
8
+ action silently do nothing.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import io
14
+ import sys
15
+ import time
16
+ from typing import Any
17
+
18
+ from use_computer.actions import MouseButton, ScrollDirection
19
+ from use_computer.backends.base import require
20
+ from use_computer.compare import Screenshot
21
+ from use_computer.coordinates import CoordinateSpace, ScreenInfo, derive_scale
22
+ from use_computer.errors import ActionFailedError, ConfigError, PermissionDeniedError
23
+ from use_computer.keys import PYNPUT_KEYS, KeyCombo
24
+
25
+ #: Seconds held between press and release, and between the two clicks of a double-click.
26
+ PRESS_HOLD = 0.02
27
+ DOUBLE_CLICK_GAP = 0.08
28
+ #: Steps a drag is interpolated over, so the application sees movement rather than a teleport.
29
+ DRAG_STEPS = 24
30
+
31
+
32
+ class LocalBackend:
33
+ """Drives this machine's display."""
34
+
35
+ name = "local"
36
+
37
+ def __init__(self, *, allow_local: bool = False, scale: float | None = None) -> None:
38
+ if not allow_local:
39
+ raise ConfigError(
40
+ "the local backend controls this machine's own keyboard and pointer and is "
41
+ "disabled by default. Enable it explicitly with `allow-local = true` in the "
42
+ "profile, or USE_COMPUTER_ALLOW_LOCAL=1."
43
+ )
44
+ self._explicit_scale = scale
45
+ pynput_mouse = require("pynput.mouse", backend=self.name, extra="local")
46
+ pynput_keyboard = require("pynput.keyboard", backend=self.name, extra="local")
47
+ mss = require("mss", backend=self.name, extra="local")
48
+
49
+ _check_macos_permissions()
50
+
51
+ self._mouse_module = pynput_mouse
52
+ self._key_module = pynput_keyboard
53
+ self._mouse = pynput_mouse.Controller()
54
+ self._keyboard = pynput_keyboard.Controller()
55
+ self._sct = mss.mss()
56
+ self._screen: ScreenInfo | None = None
57
+
58
+ # --- reporting ---------------------------------------------------------------------------
59
+
60
+ def screen_info(self) -> ScreenInfo:
61
+ if self._screen is not None:
62
+ return self._screen
63
+ monitor = self._sct.monitors[1]
64
+ width, height = int(monitor["width"]), int(monitor["height"])
65
+ shot = self._sct.grab(monitor)
66
+ # On a HiDPI display mss reports the monitor in points and grabs in pixels. That
67
+ # difference is the whole reason coordinates carry their space.
68
+ scale = self._explicit_scale
69
+ if scale is None:
70
+ scale = derive_scale(width, height, shot.width, shot.height)
71
+ self._screen = ScreenInfo(
72
+ width=width,
73
+ height=height,
74
+ screenshot_width=shot.width,
75
+ screenshot_height=shot.height,
76
+ scale=scale,
77
+ )
78
+ return self._screen
79
+
80
+ def screenshot(self) -> Screenshot:
81
+ monitor = self._sct.monitors[1]
82
+ shot = self._sct.grab(monitor)
83
+ image = _to_png(shot)
84
+ return Screenshot(
85
+ data=image,
86
+ width=shot.width,
87
+ height=shot.height,
88
+ space=CoordinateSpace.SCREENSHOT,
89
+ )
90
+
91
+ # --- acting ------------------------------------------------------------------------------
92
+
93
+ def move(self, x: int, y: int) -> None:
94
+ self._mouse.position = (x, y)
95
+
96
+ def click(self, x: int | None, y: int | None, button: MouseButton, count: int) -> None:
97
+ if x is not None and y is not None:
98
+ self.move(x, y)
99
+ pynput_button = self._button(button)
100
+ for index in range(count):
101
+ if index:
102
+ time.sleep(DOUBLE_CLICK_GAP)
103
+ self._mouse.press(pynput_button)
104
+ time.sleep(PRESS_HOLD)
105
+ self._mouse.release(pynput_button)
106
+
107
+ def drag(self, from_x: int, from_y: int, to_x: int, to_y: int, button: MouseButton) -> None:
108
+ pynput_button = self._button(button)
109
+ self.move(from_x, from_y)
110
+ self._mouse.press(pynput_button)
111
+ try:
112
+ for step in range(1, DRAG_STEPS + 1):
113
+ ratio = step / DRAG_STEPS
114
+ self.move(
115
+ round(from_x + (to_x - from_x) * ratio),
116
+ round(from_y + (to_y - from_y) * ratio),
117
+ )
118
+ time.sleep(PRESS_HOLD / 2)
119
+ finally:
120
+ self._mouse.release(pynput_button)
121
+
122
+ def scroll(
123
+ self, amount: int, direction: ScrollDirection, x: int | None, y: int | None
124
+ ) -> None:
125
+ if x is not None and y is not None:
126
+ self.move(x, y)
127
+ dx, dy = _scroll_vector(amount, direction)
128
+ self._mouse.scroll(dx, dy)
129
+
130
+ def type_text(self, text: str, rate: float) -> None:
131
+ # Typing as fast as the API allows loses characters in real applications.
132
+ for char in text:
133
+ self._keyboard.type(char)
134
+ if rate:
135
+ time.sleep(rate)
136
+
137
+ def key(self, combo: KeyCombo) -> None:
138
+ modifiers = [self._key(name) for name in combo.modifiers]
139
+ key = self._key(combo.key)
140
+ for modifier in modifiers:
141
+ self._keyboard.press(modifier)
142
+ try:
143
+ self._keyboard.press(key)
144
+ time.sleep(PRESS_HOLD)
145
+ self._keyboard.release(key)
146
+ finally:
147
+ for modifier in reversed(modifiers):
148
+ self._keyboard.release(modifier)
149
+
150
+ def close(self) -> None:
151
+ self._sct.close()
152
+
153
+ # --- mapping -----------------------------------------------------------------------------
154
+
155
+ def _button(self, button: MouseButton) -> Any:
156
+ return getattr(self._mouse_module.Button, button.value)
157
+
158
+ def _key(self, name: str) -> Any:
159
+ """Canonical key name -> pynput key. Single characters are themselves."""
160
+ attribute = PYNPUT_KEYS.get(name)
161
+ if attribute is None:
162
+ if len(name) == 1:
163
+ return name
164
+ raise ActionFailedError(f"the local backend has no mapping for key {name!r}")
165
+ return getattr(self._key_module.Key, attribute)
166
+
167
+
168
+ def _scroll_vector(amount: int, direction: ScrollDirection) -> tuple[int, int]:
169
+ if direction is ScrollDirection.UP:
170
+ return 0, amount
171
+ if direction is ScrollDirection.DOWN:
172
+ return 0, -amount
173
+ if direction is ScrollDirection.LEFT:
174
+ return -amount, 0
175
+ return amount, 0
176
+
177
+
178
+ def _to_png(shot: Any) -> bytes:
179
+ from PIL import Image
180
+
181
+ image = Image.frombytes("RGB", (shot.width, shot.height), shot.rgb)
182
+ buffer = io.BytesIO()
183
+ image.save(buffer, format="PNG")
184
+ return buffer.getvalue()
185
+
186
+
187
+ def _check_macos_permissions() -> None:
188
+ """Fail loudly when macOS has denied what pynput and mss need.
189
+
190
+ Without the permission the libraries typically do nothing at all -- a click that never
191
+ happens and never errors, which is the worst possible failure for a calling agent.
192
+ """
193
+ if sys.platform != "darwin": # pragma: no cover - platform specific
194
+ return
195
+ try: # pragma: no cover - requires macOS
196
+ from ApplicationServices import AXIsProcessTrusted
197
+ except ImportError: # pragma: no cover - pyobjc absent; let the action fail instead
198
+ return
199
+ if not AXIsProcessTrusted(): # pragma: no cover - requires macOS
200
+ raise PermissionDeniedError(
201
+ "Accessibility",
202
+ "Grant it in System Settings > Privacy & Security > Accessibility for the "
203
+ "terminal or application running use-computer, then start it again.",
204
+ )
205
+ try: # pragma: no cover - requires macOS
206
+ from Quartz import CGPreflightScreenCaptureAccess
207
+ except ImportError: # pragma: no cover
208
+ return
209
+ if not CGPreflightScreenCaptureAccess(): # pragma: no cover - requires macOS
210
+ raise PermissionDeniedError(
211
+ "Screen Recording",
212
+ "Grant it in System Settings > Privacy & Security > Screen Recording, then start "
213
+ "use-computer again.",
214
+ )