use-computer-cli 0.2.2__tar.gz → 0.3.1__tar.gz
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.
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/PKG-INFO +2 -1
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/pyproject.toml +7 -1
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/accessibility/atspi.py +85 -4
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/accessibility/ax.py +23 -1
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/accessibility/base.py +19 -1
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/accessibility/uia.py +23 -1
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/actions.py +106 -3
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/cli.py +175 -29
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/compare.py +40 -14
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/errors.py +5 -5
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/render.py +51 -2
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/runner.py +178 -10
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/selectors.py +185 -2
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/skill/SKILL.md +116 -21
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/tree.py +27 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/fake_provider.py +51 -3
- use_computer_cli-0.3.1/tests/test_blind_spots.py +157 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_cli.py +50 -3
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_compare.py +28 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_selectors.py +53 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_skill.py +6 -6
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_skill_is_true.py +41 -0
- use_computer_cli-0.3.1/tests/test_trusting_a_result.py +216 -0
- use_computer_cli-0.3.1/tests/test_which_window.py +113 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/.gitignore +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/LICENSE +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/README.md +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/__init__.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/accessibility/__init__.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/accessibility/roles.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/backends/__init__.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/backends/base.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/backends/local.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/backends/vnc.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/config.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/coordinates.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/keys.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/prune.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/src/use_computer/skill/__init__.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/__init__.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/conftest.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/fake_backend.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_atspi_hint.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_backends.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_config.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_config_init.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_coordinates.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_elements.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_keys.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_prune.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_render.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.1}/tests/test_runner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: use-computer-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Execute input on a screen for computer-use agents: move, click, drag, scroll, type, key, screenshot.
|
|
5
5
|
Project-URL: Homepage, https://github.com/applica-software-guru/use-computer
|
|
6
6
|
Author: Bruno Fortunato
|
|
@@ -52,6 +52,7 @@ Requires-Dist: mss>=10; extra == 'local'
|
|
|
52
52
|
Requires-Dist: pynput>=1.8; extra == 'local'
|
|
53
53
|
Provides-Extra: tree
|
|
54
54
|
Requires-Dist: pyobjc-framework-applicationservices>=10; (sys_platform == 'darwin') and extra == 'tree'
|
|
55
|
+
Requires-Dist: python-xlib>=0.33; (sys_platform == 'linux') and extra == 'tree'
|
|
55
56
|
Requires-Dist: uiautomation>=2.0; (sys_platform == 'win32') and extra == 'tree'
|
|
56
57
|
Provides-Extra: vnc
|
|
57
58
|
Requires-Dist: vncdotool>=1.3; extra == 'vnc'
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "use-computer-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Execute input on a screen for computer-use agents: move, click, drag, scroll, type, key, screenshot."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -52,6 +52,11 @@ vnc = ["vncdotool>=1.3"]
|
|
|
52
52
|
# bindings come from the distro (python3-gi, gir1.2-atspi-2.0) and are reached with a
|
|
53
53
|
# --system-site-packages virtualenv; the error message says so when they are absent.
|
|
54
54
|
tree = [
|
|
55
|
+
# python-xlib reads _NET_ACTIVE_WINDOW, the only reliable answer to "which window is in
|
|
56
|
+
# front" -- AT-SPI reports `active` per application and marks several at once. A pure-Python
|
|
57
|
+
# wheel with no build step, which is exactly what PyGObject was not, so it does not
|
|
58
|
+
# reintroduce BUG-003. Imported softly: absent, or on Wayland, the tool falls back.
|
|
59
|
+
"python-xlib>=0.33; sys_platform == 'linux'",
|
|
55
60
|
"uiautomation>=2.0; sys_platform == 'win32'",
|
|
56
61
|
"pyobjc-framework-ApplicationServices>=10; sys_platform == 'darwin'",
|
|
57
62
|
]
|
|
@@ -102,6 +107,7 @@ module = [
|
|
|
102
107
|
"ApplicationServices.*",
|
|
103
108
|
"Quartz.*",
|
|
104
109
|
"gi.*",
|
|
110
|
+
"Xlib.*",
|
|
105
111
|
"uiautomation.*",
|
|
106
112
|
"tomli",
|
|
107
113
|
]
|
|
@@ -17,7 +17,14 @@ from typing import Any
|
|
|
17
17
|
from use_computer.accessibility import roles
|
|
18
18
|
from use_computer.accessibility.base import require
|
|
19
19
|
from use_computer.errors import UITreeUnavailableError, UseComputerError
|
|
20
|
-
from use_computer.tree import
|
|
20
|
+
from use_computer.tree import (
|
|
21
|
+
ActiveWindow,
|
|
22
|
+
Box,
|
|
23
|
+
TreeScope,
|
|
24
|
+
TreeScopeKind,
|
|
25
|
+
UINode,
|
|
26
|
+
WindowInfo,
|
|
27
|
+
)
|
|
21
28
|
|
|
22
29
|
#: Where a distro puts PyGObject. The compiled part carries the Python version it was built for,
|
|
23
30
|
#: which is the fact that decides whether any of the advice below will work.
|
|
@@ -126,7 +133,31 @@ class AtspiProvider:
|
|
|
126
133
|
# --- reading -----------------------------------------------------------------------------
|
|
127
134
|
|
|
128
135
|
def windows(self) -> list[WindowInfo]:
|
|
129
|
-
|
|
136
|
+
found = [info for info, _ in self._window_pairs()]
|
|
137
|
+
if any(info.active for info in found):
|
|
138
|
+
return found
|
|
139
|
+
# Nothing claims `active`. Some desktops only ever report `focused`, so fall back to it
|
|
140
|
+
# rather than returning a list with no mark at all -- but never mix the two: `focused` on
|
|
141
|
+
# this desktop marks the shell, which is not a window anybody wants to act in.
|
|
142
|
+
return [
|
|
143
|
+
info.model_copy(update={"active": info.id in self._focused_ids()})
|
|
144
|
+
for info in found
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
def _focused_ids(self) -> frozenset[str]:
|
|
148
|
+
ids: set[str] = set()
|
|
149
|
+
try:
|
|
150
|
+
desktop = self._desktop()
|
|
151
|
+
for app_index, app in enumerate(self._children(desktop)):
|
|
152
|
+
try:
|
|
153
|
+
for index, window in enumerate(self._children(app)):
|
|
154
|
+
if "focused" in set(self._states(window)):
|
|
155
|
+
ids.add(f"0/{app_index}/{index}")
|
|
156
|
+
except Exception:
|
|
157
|
+
continue
|
|
158
|
+
except Exception:
|
|
159
|
+
return frozenset()
|
|
160
|
+
return frozenset(ids)
|
|
130
161
|
|
|
131
162
|
def _window_pairs(self) -> list[tuple[WindowInfo, Any]]:
|
|
132
163
|
"""Every window, with the accessible it describes, so a match can be acted on."""
|
|
@@ -147,7 +178,7 @@ class AtspiProvider:
|
|
|
147
178
|
app=app_name,
|
|
148
179
|
pid=pid if pid > 0 else None,
|
|
149
180
|
box=self._box(window),
|
|
150
|
-
active="active" in states
|
|
181
|
+
active="active" in states,
|
|
151
182
|
),
|
|
152
183
|
window,
|
|
153
184
|
)
|
|
@@ -262,7 +293,7 @@ class AtspiProvider:
|
|
|
262
293
|
|
|
263
294
|
def _is_active(self, window: Any) -> bool:
|
|
264
295
|
states = set(self._states(window))
|
|
265
|
-
return "active" in states
|
|
296
|
+
return "active" in states
|
|
266
297
|
|
|
267
298
|
#: AT-SPI reports an element that is not currently rendered at INT_MIN with a 1x1 size --
|
|
268
299
|
#: the items of a closed menu, for instance. That is a sentinel, not a position, and letting
|
|
@@ -378,6 +409,56 @@ class AtspiProvider:
|
|
|
378
409
|
return bool(interface.do_action(index))
|
|
379
410
|
return False
|
|
380
411
|
|
|
412
|
+
def active_window(self) -> ActiveWindow | None:
|
|
413
|
+
"""Ask the window manager, because AT-SPI cannot answer this.
|
|
414
|
+
|
|
415
|
+
`_NET_ACTIVE_WINDOW` on the root window names exactly one window; AT-SPI's `active` state
|
|
416
|
+
is per application and marked three at once on the desktop that produced the bug. Imported
|
|
417
|
+
softly on purpose: no python-xlib, or a Wayland session that publishes no such property,
|
|
418
|
+
and the caller falls back to the flags.
|
|
419
|
+
"""
|
|
420
|
+
try:
|
|
421
|
+
from Xlib import X, display
|
|
422
|
+
except ImportError:
|
|
423
|
+
return None
|
|
424
|
+
try:
|
|
425
|
+
connection = display.Display()
|
|
426
|
+
except Exception:
|
|
427
|
+
return None # no X server: Wayland, or no session at all
|
|
428
|
+
try:
|
|
429
|
+
root = connection.screen().root
|
|
430
|
+
active = root.get_full_property(
|
|
431
|
+
connection.intern_atom("_NET_ACTIVE_WINDOW"), X.AnyPropertyType
|
|
432
|
+
)
|
|
433
|
+
if active is None or not active.value:
|
|
434
|
+
return None
|
|
435
|
+
window = connection.create_resource_object("window", active.value[0])
|
|
436
|
+
pid = window.get_full_property(
|
|
437
|
+
connection.intern_atom("_NET_WM_PID"), X.AnyPropertyType
|
|
438
|
+
)
|
|
439
|
+
name = window.get_full_property(
|
|
440
|
+
connection.intern_atom("_NET_WM_NAME"), connection.intern_atom("UTF8_STRING")
|
|
441
|
+
)
|
|
442
|
+
return ActiveWindow(
|
|
443
|
+
pid=int(pid.value[0]) if pid is not None and pid.value else None,
|
|
444
|
+
title=name.value.decode("utf-8", "replace") if name is not None else None,
|
|
445
|
+
)
|
|
446
|
+
except Exception:
|
|
447
|
+
# A hint that fails is not an error: the caller has a working fallback.
|
|
448
|
+
return None
|
|
449
|
+
finally:
|
|
450
|
+
with suppress(Exception):
|
|
451
|
+
connection.close()
|
|
452
|
+
|
|
453
|
+
def activate(self, window_id: str) -> bool:
|
|
454
|
+
"""AT-SPI offers no raise for a window.
|
|
455
|
+
|
|
456
|
+
A window accessible reports no actions at all -- verified: `It supports: none` -- so there
|
|
457
|
+
is nothing native to call here. False tells the caller to focus a descendant instead,
|
|
458
|
+
which is what actually moves `_NET_ACTIVE_WINDOW`.
|
|
459
|
+
"""
|
|
460
|
+
return False
|
|
461
|
+
|
|
381
462
|
def close(self) -> None:
|
|
382
463
|
self._index = {}
|
|
383
464
|
|
|
@@ -15,7 +15,14 @@ from typing import Any
|
|
|
15
15
|
from use_computer.accessibility import roles
|
|
16
16
|
from use_computer.accessibility.base import require
|
|
17
17
|
from use_computer.errors import PermissionDeniedError
|
|
18
|
-
from use_computer.tree import
|
|
18
|
+
from use_computer.tree import (
|
|
19
|
+
ActiveWindow,
|
|
20
|
+
Box,
|
|
21
|
+
TreeScope,
|
|
22
|
+
TreeScopeKind,
|
|
23
|
+
UINode,
|
|
24
|
+
WindowInfo,
|
|
25
|
+
)
|
|
19
26
|
|
|
20
27
|
#: kAXErrorAPIDisabled -- the process is not trusted for accessibility.
|
|
21
28
|
API_DISABLED = -25211
|
|
@@ -197,6 +204,21 @@ class AxProvider:
|
|
|
197
204
|
roles.COLLAPSE: "AXPress",
|
|
198
205
|
}
|
|
199
206
|
|
|
207
|
+
def active_window(self) -> ActiveWindow | None:
|
|
208
|
+
"""This platform's own `active` flag is already per window, so there is nothing to add."""
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
def activate(self, window_id: str) -> bool:
|
|
212
|
+
"""macOS windows do accept a raise of their own: AXRaise."""
|
|
213
|
+
element = self._index.get(window_id)
|
|
214
|
+
if element is None:
|
|
215
|
+
return False
|
|
216
|
+
try:
|
|
217
|
+
status = self._api.AXUIElementPerformAction(element, "AXRaise")
|
|
218
|
+
except Exception:
|
|
219
|
+
return False
|
|
220
|
+
return bool(status == 0)
|
|
221
|
+
|
|
200
222
|
def perform(self, node_id: str, action: str, value: str | None) -> bool:
|
|
201
223
|
element = self._index.get(node_id)
|
|
202
224
|
if element is None:
|
|
@@ -15,7 +15,7 @@ from types import ModuleType
|
|
|
15
15
|
from typing import Protocol, runtime_checkable
|
|
16
16
|
|
|
17
17
|
from use_computer.errors import UITreeUnavailableError
|
|
18
|
-
from use_computer.tree import TreeScope, UINode, WindowInfo
|
|
18
|
+
from use_computer.tree import ActiveWindow, TreeScope, UINode, WindowInfo
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
@runtime_checkable
|
|
@@ -50,6 +50,24 @@ class AccessibilityProvider(Protocol):
|
|
|
50
50
|
nothing at all. ``False`` is what makes ``--via auto`` fall back to a coordinate click.
|
|
51
51
|
"""
|
|
52
52
|
|
|
53
|
+
def activate(self, window_id: str) -> bool:
|
|
54
|
+
"""Bring a window forward, natively, if this platform has a way.
|
|
55
|
+
|
|
56
|
+
``True`` means the window was raised. ``False`` is not a failure: it means this platform
|
|
57
|
+
offers no raise for a window object, and the caller should focus a descendant instead --
|
|
58
|
+
which raises the top-level window on all three. AT-SPI is the ``False`` case; a window
|
|
59
|
+
accessible there reports no actions at all.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def active_window(self) -> ActiveWindow | None:
|
|
63
|
+
"""What the window manager says is in front, as a pid and a title.
|
|
64
|
+
|
|
65
|
+
A *hint*. ``None`` means this platform has nothing better than the per-window flags, which
|
|
66
|
+
is the honest answer on Wayland or without the optional binding. Matching it to a window is
|
|
67
|
+
policy and lives in ``selectors.mark_active``: a rule that lives in a provider is a rule
|
|
68
|
+
the other two platforms drift from, which has already happened once here.
|
|
69
|
+
"""
|
|
70
|
+
|
|
53
71
|
def close(self) -> None:
|
|
54
72
|
"""Release whatever the provider holds."""
|
|
55
73
|
|
|
@@ -11,7 +11,14 @@ from typing import Any
|
|
|
11
11
|
from use_computer.accessibility import roles
|
|
12
12
|
from use_computer.accessibility.base import require
|
|
13
13
|
from use_computer.errors import UITreeUnavailableError
|
|
14
|
-
from use_computer.tree import
|
|
14
|
+
from use_computer.tree import (
|
|
15
|
+
ActiveWindow,
|
|
16
|
+
Box,
|
|
17
|
+
TreeScope,
|
|
18
|
+
TreeScopeKind,
|
|
19
|
+
UINode,
|
|
20
|
+
WindowInfo,
|
|
21
|
+
)
|
|
15
22
|
|
|
16
23
|
|
|
17
24
|
class UiaProvider:
|
|
@@ -167,6 +174,21 @@ class UiaProvider:
|
|
|
167
174
|
|
|
168
175
|
# --- acting ------------------------------------------------------------------------------
|
|
169
176
|
|
|
177
|
+
def active_window(self) -> ActiveWindow | None:
|
|
178
|
+
"""This platform's own `active` flag is already per window, so there is nothing to add."""
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
def activate(self, window_id: str) -> bool:
|
|
182
|
+
"""Windows has a native raise: `SetActive` on the top-level control."""
|
|
183
|
+
control = self._index.get(window_id)
|
|
184
|
+
if control is None:
|
|
185
|
+
return False
|
|
186
|
+
try:
|
|
187
|
+
return bool(control.SetActive())
|
|
188
|
+
except Exception:
|
|
189
|
+
# Not every control exposes it, and the caller has a working fallback.
|
|
190
|
+
return False
|
|
191
|
+
|
|
170
192
|
def perform(self, node_id: str, action: str, value: str | None) -> bool:
|
|
171
193
|
control = self._index.get(node_id)
|
|
172
194
|
if control is None:
|
|
@@ -7,6 +7,7 @@ in actuation units and canonical key names.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
from difflib import get_close_matches
|
|
10
11
|
from enum import Enum
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Annotated, Any, Literal
|
|
@@ -48,7 +49,25 @@ class BaseAction(BaseModel):
|
|
|
48
49
|
return None
|
|
49
50
|
|
|
50
51
|
|
|
51
|
-
class
|
|
52
|
+
class _InAWindow(BaseAction):
|
|
53
|
+
"""An action whose coordinate belongs to a particular window.
|
|
54
|
+
|
|
55
|
+
A coordinate lands on whatever window is in front. That is what a bare coordinate has always
|
|
56
|
+
meant, and it is fine until the agent meant a particular window -- which is most of the time.
|
|
57
|
+
Naming it here is how the caller says so, and the window is brought forward before the
|
|
58
|
+
coordinate is sent. Measured: two drags aimed at a canvas selected text in a terminal instead,
|
|
59
|
+
and `--verify` then confirmed the wrong action with the wrong evidence.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
window: TreeScope | None = None
|
|
63
|
+
|
|
64
|
+
@field_validator("window", mode="before")
|
|
65
|
+
@classmethod
|
|
66
|
+
def _parse_scope(cls, value: Any) -> Any:
|
|
67
|
+
return TreeScope.parse(value) if isinstance(value, str) else value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _Positioned(_InAWindow):
|
|
52
71
|
"""An action that may carry a coordinate. Omitting it acts where the pointer already is."""
|
|
53
72
|
|
|
54
73
|
x: int | None = None
|
|
@@ -130,7 +149,7 @@ class RightClickAction(_PositionedOrSelected):
|
|
|
130
149
|
action: Literal["right_click"] = "right_click"
|
|
131
150
|
|
|
132
151
|
|
|
133
|
-
class DragAction(
|
|
152
|
+
class DragAction(_InAWindow):
|
|
134
153
|
action: Literal["drag"] = "drag"
|
|
135
154
|
from_x: int
|
|
136
155
|
from_y: int
|
|
@@ -297,6 +316,23 @@ class ShowMenuAction(_ElementAction):
|
|
|
297
316
|
action: Literal["show_menu"] = "show_menu"
|
|
298
317
|
|
|
299
318
|
|
|
319
|
+
class ActivateAction(BaseAction):
|
|
320
|
+
"""Bring a window forward and give it keyboard focus.
|
|
321
|
+
|
|
322
|
+
The only action whose target is a window rather than an element or a point. A window object
|
|
323
|
+
exposes no actions of its own on any of the three platforms -- AT-SPI answers `It supports:
|
|
324
|
+
none` -- so it is done by focusing a descendant, which raises the top-level window everywhere.
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
action: Literal["activate"] = "activate"
|
|
328
|
+
window: TreeScope
|
|
329
|
+
|
|
330
|
+
@field_validator("window", mode="before")
|
|
331
|
+
@classmethod
|
|
332
|
+
def _parse_scope(cls, value: Any) -> Any:
|
|
333
|
+
return TreeScope.parse(value) if isinstance(value, str) else value
|
|
334
|
+
|
|
335
|
+
|
|
300
336
|
Action = Annotated[
|
|
301
337
|
MoveAction
|
|
302
338
|
| ClickAction
|
|
@@ -315,7 +351,8 @@ Action = Annotated[
|
|
|
315
351
|
| CollapseAction
|
|
316
352
|
| SelectAction
|
|
317
353
|
| SetValueAction
|
|
318
|
-
| ShowMenuAction
|
|
354
|
+
| ShowMenuAction
|
|
355
|
+
| ActivateAction,
|
|
319
356
|
Field(discriminator="action"),
|
|
320
357
|
]
|
|
321
358
|
|
|
@@ -339,6 +376,72 @@ def selector_of(action: Action) -> NodeSelector | None:
|
|
|
339
376
|
#: Parses a batch file: a JSON array of action objects, discriminated on `action`.
|
|
340
377
|
ActionListAdapter: TypeAdapter[list[Action]] = TypeAdapter(list[Action])
|
|
341
378
|
|
|
379
|
+
#: Every action name, in the spelling the discriminator uses.
|
|
380
|
+
ACTION_NAMES: tuple[str, ...] = (
|
|
381
|
+
"move",
|
|
382
|
+
"click",
|
|
383
|
+
"double_click",
|
|
384
|
+
"right_click",
|
|
385
|
+
"drag",
|
|
386
|
+
"scroll",
|
|
387
|
+
"type",
|
|
388
|
+
"key",
|
|
389
|
+
"screenshot",
|
|
390
|
+
"tree",
|
|
391
|
+
"windows",
|
|
392
|
+
"focus",
|
|
393
|
+
"toggle",
|
|
394
|
+
"expand",
|
|
395
|
+
"collapse",
|
|
396
|
+
"select",
|
|
397
|
+
"set_value",
|
|
398
|
+
"show_menu",
|
|
399
|
+
"activate",
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _cli_spelling(name: str) -> str:
|
|
404
|
+
"""How this action is spelled as a command: `set_value` is typed `set-value`."""
|
|
405
|
+
return name.replace("_", "-")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def normalise_action_names(data: Any) -> Any:
|
|
409
|
+
"""Accept the CLI's own spelling of an action name inside a batch.
|
|
410
|
+
|
|
411
|
+
The command is `use-computer set-value`; the batch wanted `set_value`. Same action, two
|
|
412
|
+
spellings, and nothing said so -- an agent that has just read `set-value --help` has no reason
|
|
413
|
+
to expect a different name three lines later.
|
|
414
|
+
"""
|
|
415
|
+
if not isinstance(data, list):
|
|
416
|
+
return data
|
|
417
|
+
out = []
|
|
418
|
+
for item in data:
|
|
419
|
+
if isinstance(item, dict) and isinstance(item.get("action"), str):
|
|
420
|
+
item = {**item, "action": item["action"].replace("-", "_")}
|
|
421
|
+
out.append(item)
|
|
422
|
+
return out
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def check_action_names(data: Any) -> None:
|
|
426
|
+
"""Fail on an unknown action with a sentence, before pydantic offers its union.
|
|
427
|
+
|
|
428
|
+
The union's own error is four hundred characters naming every variant except the one the
|
|
429
|
+
caller should have written. This is a message to somebody mid-task who cannot see the code.
|
|
430
|
+
"""
|
|
431
|
+
if not isinstance(data, list):
|
|
432
|
+
return
|
|
433
|
+
for index, item in enumerate(data):
|
|
434
|
+
if not isinstance(item, dict):
|
|
435
|
+
continue
|
|
436
|
+
name = item.get("action")
|
|
437
|
+
if not isinstance(name, str) or name in ACTION_NAMES:
|
|
438
|
+
continue
|
|
439
|
+
close = get_close_matches(name, ACTION_NAMES, n=1, cutoff=0.6)
|
|
440
|
+
suggestion = f" Did you mean {_cli_spelling(close[0])!r}?" if close else ""
|
|
441
|
+
raise ValueError(
|
|
442
|
+
f"unknown action {_cli_spelling(name)!r} at index {index}.{suggestion}"
|
|
443
|
+
)
|
|
444
|
+
|
|
342
445
|
#: Parses a single action object.
|
|
343
446
|
ActionAdapter: TypeAdapter[Action] = TypeAdapter(Action)
|
|
344
447
|
|