AutoMonkey 0.4.1__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.
- automonkey/__init__.py +37 -0
- automonkey/ai/__init__.py +54 -0
- automonkey/ai/__main__.py +76 -0
- automonkey/ai/mcp_launch.py +62 -0
- automonkey/ai/mcp_server.py +114 -0
- automonkey/ai/ocr.py +149 -0
- automonkey/ai/screen.py +202 -0
- automonkey/ai/script.py +190 -0
- automonkey/ai/windows.py +185 -0
- automonkey/app_funcs.py +201 -0
- automonkey/atspi_funcs.py +1093 -0
- automonkey/automonkey.py +300 -0
- automonkey/constants.py +120 -0
- automonkey/exceptions.py +16 -0
- automonkey/img_funcs.py +301 -0
- automonkey/key_funcs.py +279 -0
- automonkey/mouse_funcs.py +37 -0
- automonkey/mouse_tracker.py +133 -0
- automonkey/pywinauto_funcs.py +343 -0
- automonkey/utils.py +169 -0
- automonkey-0.4.1.dist-info/METADATA +517 -0
- automonkey-0.4.1.dist-info/RECORD +27 -0
- automonkey-0.4.1.dist-info/WHEEL +5 -0
- automonkey-0.4.1.dist-info/entry_points.txt +3 -0
- automonkey-0.4.1.dist-info/licenses/AUTHORS.rst +13 -0
- automonkey-0.4.1.dist-info/licenses/LICENSE +33 -0
- automonkey-0.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1093 @@
|
|
|
1
|
+
"""Control-level Linux automation based on AT-SPI (gi.repository.Atspi).
|
|
2
|
+
|
|
3
|
+
Mirrors the public functions and signatures of pywinauto_funcs.py so a `chain` script
|
|
4
|
+
using the control actions (click_control, type_in_control, ...) runs on both Windows
|
|
5
|
+
and Linux unchanged. See pywinauto_for_linux.md for the implementation notes and the
|
|
6
|
+
known limits (Qt/Chrome/Electron accessibility flags, Wayland mouse-click fallback, ...).
|
|
7
|
+
|
|
8
|
+
Every function takes:
|
|
9
|
+
window: title of the top-level window (regex, matched anywhere in the title,
|
|
10
|
+
like focus/close/minimize) or an Atspi.Accessible already resolved by get_window.
|
|
11
|
+
control: what to act on inside the window:
|
|
12
|
+
- None: the window itself
|
|
13
|
+
- str: "best match" name (see get_control, section 3)
|
|
14
|
+
- dict: search criteria, e.g. {"title": "Save", "control_type": "Button"}
|
|
15
|
+
backend: "atspi" (default and only backend on Linux).
|
|
16
|
+
timeout: seconds to wait for the window/control to appear.
|
|
17
|
+
|
|
18
|
+
Use list_controls(window) to see the names of the controls in a window.
|
|
19
|
+
AT-SPI needs distro packages, not pip: python3-gi, gir1.2-atspi-2.0, at-spi2-core
|
|
20
|
+
(Debian/Ubuntu/Raspberry Pi OS: `sudo apt-get install python3-gi gir1.2-atspi-2.0 at-spi2-core`),
|
|
21
|
+
plus accessibility enabled: `gsettings set org.gnome.desktop.interface toolkit-accessibility true`
|
|
22
|
+
(restart apps started before that for it to take effect). Also make sure the app is not
|
|
23
|
+
launched with NO_AT_BRIDGE=1 in its environment - that silently disables AT-SPI registration.
|
|
24
|
+
"""
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import shlex
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
import warnings
|
|
32
|
+
|
|
33
|
+
import pyautogui
|
|
34
|
+
|
|
35
|
+
from .key_funcs import write
|
|
36
|
+
from .key_funcs import press
|
|
37
|
+
from .key_funcs import hotkey as keys2
|
|
38
|
+
from .utils import copy
|
|
39
|
+
|
|
40
|
+
_VALID_BACKENDS = ("atspi", None)
|
|
41
|
+
|
|
42
|
+
# pywinauto control_type -> AT-SPI Role enum member names (not the display strings from
|
|
43
|
+
# get_role_name(), which differ by toolkit/version - see pywinauto_for_linux.md findings).
|
|
44
|
+
# Not exhaustive; extend as new control_type values are needed.
|
|
45
|
+
_CONTROL_TYPE_TO_ROLES = {
|
|
46
|
+
"Button": ("BUTTON", "PUSH_BUTTON", "TOGGLE_BUTTON"),
|
|
47
|
+
"CheckBox": ("CHECK_BOX",),
|
|
48
|
+
"RadioButton": ("RADIO_BUTTON",),
|
|
49
|
+
"ComboBox": ("COMBO_BOX",),
|
|
50
|
+
"Edit": ("TEXT", "ENTRY", "PASSWORD_TEXT"),
|
|
51
|
+
"Document": (
|
|
52
|
+
"DOCUMENT_TEXT", "DOCUMENT_FRAME", "DOCUMENT_WEB", "DOCUMENT_EMAIL",
|
|
53
|
+
"DOCUMENT_PRESENTATION", "DOCUMENT_SPREADSHEET",
|
|
54
|
+
),
|
|
55
|
+
"Static": ("LABEL",),
|
|
56
|
+
"Text": ("LABEL", "TEXT"),
|
|
57
|
+
"MenuItem": ("MENU_ITEM", "CHECK_MENU_ITEM", "RADIO_MENU_ITEM", "TEAROFF_MENU_ITEM"),
|
|
58
|
+
"Menu": ("MENU", "POPUP_MENU"),
|
|
59
|
+
"MenuBar": ("MENU_BAR",),
|
|
60
|
+
"List": ("LIST", "LIST_BOX"),
|
|
61
|
+
"ListItem": ("LIST_ITEM",),
|
|
62
|
+
"Tab": ("PAGE_TAB",),
|
|
63
|
+
"TabControl": ("PAGE_TAB_LIST",),
|
|
64
|
+
"Window": ("FRAME", "DIALOG"),
|
|
65
|
+
"Dialog": ("DIALOG",),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Keep lookups fast on huge trees (e.g. a browser's page content): cap how deep and how
|
|
69
|
+
# many nodes a single find walks, per pywinauto_for_linux.md section 3.
|
|
70
|
+
_MAX_SEARCH_DEPTH = 40
|
|
71
|
+
_MAX_SEARCH_NODES = 6000
|
|
72
|
+
|
|
73
|
+
_CLOSEST_NAMES_LIMIT = 20
|
|
74
|
+
|
|
75
|
+
_QT_CHROME_ELECTRON_HINT = (
|
|
76
|
+
"If this looks like the whole app, not just one control: some toolkits don't expose "
|
|
77
|
+
"AT-SPI by default. Qt apps need QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1 in their environment "
|
|
78
|
+
"before they start; Chrome/Electron apps need the --force-renderer-accessibility flag; "
|
|
79
|
+
"self-drawn/custom-rendered UIs (e.g. some games, canvas-based apps) cannot expose a tree "
|
|
80
|
+
"at all. See pywinauto_for_linux.md known limits."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Set once _atspi() has confirmed the bus is reachable and warned (if needed) about
|
|
84
|
+
# toolkit-accessibility - so that check only runs once per process, not on every call.
|
|
85
|
+
_env_checked = False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _check_backend(backend: str):
|
|
89
|
+
if backend not in _VALID_BACKENDS:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"backend={backend!r} is not valid on Linux. Use backend='atspi' (the default, "
|
|
92
|
+
"can also be omitted) here; 'uia'/'win32' are pywinauto backends for Windows "
|
|
93
|
+
"(automonkey/pywinauto_funcs.py)."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _atspi_bus_reachable():
|
|
98
|
+
"""Best-effort, SAFE pre-flight check for the AT-SPI D-Bus service (org.a11y.Bus),
|
|
99
|
+
run via a separate dbus-send process rather than libatspi/Atspi itself: when the bus
|
|
100
|
+
is genuinely unreachable, libatspi's C layer prints a fatal "dbind-ERROR" and aborts
|
|
101
|
+
the *entire process* - there is no Python exception to catch, so Atspi.get_desktop()
|
|
102
|
+
(or anything that calls it) must never be the thing that finds this out. Returns
|
|
103
|
+
True/False, or None if the check itself could not run (e.g. no dbus-send installed) -
|
|
104
|
+
that's "inconclusive", not "unreachable", so callers should not hard-fail on None."""
|
|
105
|
+
try:
|
|
106
|
+
result = subprocess.run(
|
|
107
|
+
["dbus-send", "--session", "--dest=org.a11y.Bus", "--type=method_call",
|
|
108
|
+
"--print-reply", "/org/a11y/bus", "org.a11y.Bus.GetAddress"],
|
|
109
|
+
capture_output=True, text=True, timeout=3, check=False,
|
|
110
|
+
)
|
|
111
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
112
|
+
return None
|
|
113
|
+
return result.returncode == 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _check_accessibility_enabled():
|
|
117
|
+
"""Best-effort, warn-only: GNOME's toolkit-accessibility gsetting being off means
|
|
118
|
+
GTK apps won't register on AT-SPI until it's turned on and they're restarted. Not
|
|
119
|
+
every desktop has this schema (this box is labwc/Raspberry Pi Desktop, not GNOME,
|
|
120
|
+
yet the key still applies since GTK itself reads it) - a missing schema is silently
|
|
121
|
+
not a problem, since most AT-SPI apps that matter are GTK and respect this key."""
|
|
122
|
+
try:
|
|
123
|
+
result = subprocess.run(
|
|
124
|
+
["gsettings", "get", "org.gnome.desktop.interface", "toolkit-accessibility"],
|
|
125
|
+
capture_output=True, text=True, timeout=2, check=False,
|
|
126
|
+
)
|
|
127
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
128
|
+
return
|
|
129
|
+
if result.returncode == 0 and result.stdout.strip() == "false":
|
|
130
|
+
warnings.warn(
|
|
131
|
+
"org.gnome.desktop.interface toolkit-accessibility is 'false'. GTK apps "
|
|
132
|
+
"already running won't register on AT-SPI until it's enabled AND they're "
|
|
133
|
+
"restarted: gsettings set org.gnome.desktop.interface toolkit-accessibility true",
|
|
134
|
+
stacklevel=3,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _atspi():
|
|
139
|
+
"""Lazily import gi.repository.Atspi, with a clear error if it is missing or if this
|
|
140
|
+
is macOS (not implemented there yet). Also runs the one-time environment checks
|
|
141
|
+
above before returning, so every public function gets a clear, safe error/warning
|
|
142
|
+
up front instead of either a confusing "not found" or - far worse - a process abort
|
|
143
|
+
the first time something actually calls into Atspi against a dead bus."""
|
|
144
|
+
if sys.platform == "darwin":
|
|
145
|
+
raise NotImplementedError(
|
|
146
|
+
"Control actions are not implemented on macOS yet. They are available on "
|
|
147
|
+
"Windows (backend='uia'/'win32', pywinauto_funcs.py) and Linux "
|
|
148
|
+
"(backend='atspi', this module)."
|
|
149
|
+
)
|
|
150
|
+
try:
|
|
151
|
+
import gi # pylint: disable=import-outside-toplevel
|
|
152
|
+
gi.require_version("Atspi", "2.0")
|
|
153
|
+
from gi.repository import Atspi # pylint: disable=import-outside-toplevel
|
|
154
|
+
except (ImportError, ValueError) as err:
|
|
155
|
+
raise ImportError(
|
|
156
|
+
"This function needs the AT-SPI Python bindings (Linux only). Install the "
|
|
157
|
+
"distro packages, e.g. on Debian/Ubuntu/Raspberry Pi OS: "
|
|
158
|
+
"sudo apt-get install python3-gi gir1.2-atspi-2.0 at-spi2-core; then enable "
|
|
159
|
+
"accessibility: gsettings set org.gnome.desktop.interface toolkit-accessibility true"
|
|
160
|
+
) from err
|
|
161
|
+
|
|
162
|
+
global _env_checked # pylint: disable=global-statement
|
|
163
|
+
if not _env_checked:
|
|
164
|
+
if _atspi_bus_reachable() is False:
|
|
165
|
+
raise ConnectionError(
|
|
166
|
+
"The AT-SPI accessibility bus is not reachable. Make sure at-spi2-core is "
|
|
167
|
+
"installed and the session D-Bus is running: sudo apt-get install "
|
|
168
|
+
"at-spi2-core; then log out and back in (or restart the session) - it is "
|
|
169
|
+
"D-Bus-activated, so installing it is usually enough without a reboot."
|
|
170
|
+
)
|
|
171
|
+
_check_accessibility_enabled()
|
|
172
|
+
_env_checked = True # only after a successful (or inconclusive) check, so a
|
|
173
|
+
# transient failure (e.g. fixed mid-session) gets re-checked on the next call
|
|
174
|
+
|
|
175
|
+
return Atspi
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _prepare(backend: str):
|
|
179
|
+
"""Shared validation every public function runs first: checks the backend name,
|
|
180
|
+
then lazily imports Atspi (raising the errors documented in _check_backend/_atspi)."""
|
|
181
|
+
_check_backend(backend)
|
|
182
|
+
return _atspi()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _safe_name(acc) -> str:
|
|
186
|
+
try:
|
|
187
|
+
return acc.get_name() or ""
|
|
188
|
+
except Exception: # pylint: disable=broad-except
|
|
189
|
+
return ""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _safe_role_name(acc) -> str:
|
|
193
|
+
try:
|
|
194
|
+
return acc.get_role_name() or ""
|
|
195
|
+
except Exception: # pylint: disable=broad-except
|
|
196
|
+
return "?"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _has_no_children(acc) -> bool:
|
|
200
|
+
try:
|
|
201
|
+
return acc.get_child_count() == 0
|
|
202
|
+
except Exception: # pylint: disable=broad-except
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _is_showing(Atspi, acc) -> bool:
|
|
207
|
+
try:
|
|
208
|
+
states = acc.get_state_set()
|
|
209
|
+
return states.contains(Atspi.StateType.SHOWING) or states.contains(Atspi.StateType.VISIBLE)
|
|
210
|
+
except Exception: # pylint: disable=broad-except
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _iter_top_level_windows():
|
|
215
|
+
"""Yield every top-level window (direct child of every application) across the
|
|
216
|
+
whole AT-SPI desktop - get_window searches "across all applications"."""
|
|
217
|
+
Atspi = _atspi()
|
|
218
|
+
desktop = Atspi.get_desktop(0)
|
|
219
|
+
for i in range(desktop.get_child_count()):
|
|
220
|
+
try:
|
|
221
|
+
app = desktop.get_child_at_index(i)
|
|
222
|
+
except Exception: # pylint: disable=broad-except
|
|
223
|
+
continue
|
|
224
|
+
if app is None:
|
|
225
|
+
continue
|
|
226
|
+
try:
|
|
227
|
+
n = app.get_child_count()
|
|
228
|
+
except Exception: # pylint: disable=broad-except
|
|
229
|
+
continue
|
|
230
|
+
for j in range(n):
|
|
231
|
+
try:
|
|
232
|
+
win = app.get_child_at_index(j)
|
|
233
|
+
except Exception: # pylint: disable=broad-except
|
|
234
|
+
continue
|
|
235
|
+
if win is not None:
|
|
236
|
+
yield win
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _walk(root, visit, max_depth: int = _MAX_SEARCH_DEPTH, max_nodes: int = _MAX_SEARCH_NODES):
|
|
240
|
+
"""Depth-first walk from root calling visit(node) on each node, including root.
|
|
241
|
+
Stops past max_depth or once max_nodes nodes have been visited, so a huge subtree
|
|
242
|
+
(e.g. a browser's page content) cannot make a single lookup slow."""
|
|
243
|
+
budget = [max_nodes]
|
|
244
|
+
|
|
245
|
+
def _rec(node, depth):
|
|
246
|
+
if node is None or budget[0] <= 0:
|
|
247
|
+
return
|
|
248
|
+
budget[0] -= 1
|
|
249
|
+
visit(node)
|
|
250
|
+
if depth >= max_depth or budget[0] <= 0:
|
|
251
|
+
return
|
|
252
|
+
try:
|
|
253
|
+
n = node.get_child_count()
|
|
254
|
+
except Exception: # pylint: disable=broad-except
|
|
255
|
+
return
|
|
256
|
+
for i in range(n):
|
|
257
|
+
if budget[0] <= 0:
|
|
258
|
+
return
|
|
259
|
+
try:
|
|
260
|
+
child = node.get_child_at_index(i)
|
|
261
|
+
except Exception: # pylint: disable=broad-except
|
|
262
|
+
continue
|
|
263
|
+
_rec(child, depth + 1)
|
|
264
|
+
|
|
265
|
+
_rec(root, 0)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _find_all(root, predicate, max_depth: int = _MAX_SEARCH_DEPTH, max_nodes: int = _MAX_SEARCH_NODES):
|
|
269
|
+
found = []
|
|
270
|
+
|
|
271
|
+
def visit(node):
|
|
272
|
+
try:
|
|
273
|
+
if predicate(node):
|
|
274
|
+
found.append(node)
|
|
275
|
+
except Exception: # pylint: disable=broad-except
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
_walk(root, visit, max_depth, max_nodes)
|
|
279
|
+
return found
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _collect_names(root, limit: int = _CLOSEST_NAMES_LIMIT):
|
|
283
|
+
"""For error messages: the names actually present under root, for "closest match" hints."""
|
|
284
|
+
names = []
|
|
285
|
+
|
|
286
|
+
def visit(node):
|
|
287
|
+
name = _safe_name(node)
|
|
288
|
+
if name:
|
|
289
|
+
names.append(f"{name!r} ({_safe_role_name(node)})")
|
|
290
|
+
|
|
291
|
+
_walk(root, visit)
|
|
292
|
+
# de-duplicate while keeping order
|
|
293
|
+
seen = set()
|
|
294
|
+
unique = []
|
|
295
|
+
for entry in names:
|
|
296
|
+
if entry not in seen:
|
|
297
|
+
seen.add(entry)
|
|
298
|
+
unique.append(entry)
|
|
299
|
+
return unique[:limit]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _roles_for_control_type(Atspi, control_type: str):
|
|
303
|
+
try:
|
|
304
|
+
role_names = _CONTROL_TYPE_TO_ROLES[control_type]
|
|
305
|
+
except KeyError as err:
|
|
306
|
+
raise ValueError(
|
|
307
|
+
f"control_type={control_type!r} is not mapped to an AT-SPI role. Known: "
|
|
308
|
+
f"{sorted(_CONTROL_TYPE_TO_ROLES)}. Extend _CONTROL_TYPE_TO_ROLES in "
|
|
309
|
+
"atspi_funcs.py, or match on a role name directly."
|
|
310
|
+
) from err
|
|
311
|
+
roles = tuple(getattr(Atspi.Role, name) for name in role_names if hasattr(Atspi.Role, name))
|
|
312
|
+
if not roles:
|
|
313
|
+
raise ValueError(
|
|
314
|
+
f"None of the AT-SPI roles for control_type={control_type!r} ({role_names}) "
|
|
315
|
+
"exist in this Atspi binding version."
|
|
316
|
+
)
|
|
317
|
+
return roles
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _accessible_id(acc):
|
|
321
|
+
"""AT-SPI accessible-id (2.34+), else an "id" attribute, else None (caller falls
|
|
322
|
+
back to matching on the accessible name - see pywinauto_for_linux.md section 3)."""
|
|
323
|
+
try:
|
|
324
|
+
acc_id = acc.get_accessible_id()
|
|
325
|
+
if acc_id:
|
|
326
|
+
return acc_id
|
|
327
|
+
except Exception: # pylint: disable=broad-except
|
|
328
|
+
pass
|
|
329
|
+
try:
|
|
330
|
+
attrs = acc.get_attributes()
|
|
331
|
+
if attrs and attrs.get("id"):
|
|
332
|
+
return attrs["id"]
|
|
333
|
+
except Exception: # pylint: disable=broad-except
|
|
334
|
+
pass
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _matches_dict(node, criteria: dict, control_type_roles=None) -> bool:
|
|
339
|
+
"""criteria is matched as-is except "control_type", which the caller must resolve to
|
|
340
|
+
a roles tuple up front (via _roles_for_control_type) and pass as control_type_roles -
|
|
341
|
+
resolving it here, inside the per-node predicate, would let an unknown control_type's
|
|
342
|
+
ValueError get silently swallowed by _find_all's per-node exception guard instead of
|
|
343
|
+
surfacing as the clear error it should be."""
|
|
344
|
+
if "title" in criteria and _safe_name(node) != criteria["title"]:
|
|
345
|
+
return False
|
|
346
|
+
if "title_re" in criteria and not re.match(criteria["title_re"], _safe_name(node)):
|
|
347
|
+
return False
|
|
348
|
+
if control_type_roles is not None:
|
|
349
|
+
try:
|
|
350
|
+
if node.get_role() not in control_type_roles:
|
|
351
|
+
return False
|
|
352
|
+
except Exception: # pylint: disable=broad-except
|
|
353
|
+
return False
|
|
354
|
+
for key in ("auto_id", "id"):
|
|
355
|
+
if key in criteria:
|
|
356
|
+
wanted = criteria[key]
|
|
357
|
+
acc_id = _accessible_id(node)
|
|
358
|
+
actual = acc_id if acc_id is not None else _safe_name(node)
|
|
359
|
+
if actual != wanted:
|
|
360
|
+
return False
|
|
361
|
+
return True
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _best_match(Atspi, win, control: str):
|
|
365
|
+
"""pywinauto-style "best match": exact name, then name + known control-type suffix
|
|
366
|
+
(e.g. "SaveButton" -> name "Save" with a Button role), then case-insensitive substring."""
|
|
367
|
+
exact = _find_all(win, lambda n: _safe_name(n) == control)
|
|
368
|
+
if exact:
|
|
369
|
+
return exact[0]
|
|
370
|
+
|
|
371
|
+
for suffix in sorted(_CONTROL_TYPE_TO_ROLES, key=len, reverse=True):
|
|
372
|
+
if control.endswith(suffix) and len(control) > len(suffix):
|
|
373
|
+
name_part = control[: -len(suffix)]
|
|
374
|
+
roles = _roles_for_control_type(Atspi, suffix)
|
|
375
|
+
matches = _find_all(
|
|
376
|
+
win,
|
|
377
|
+
lambda n, name_part=name_part, roles=roles: (
|
|
378
|
+
_safe_name(n) == name_part and n.get_role() in roles
|
|
379
|
+
),
|
|
380
|
+
)
|
|
381
|
+
if matches:
|
|
382
|
+
return matches[0]
|
|
383
|
+
|
|
384
|
+
lowered = control.lower()
|
|
385
|
+
substr = _find_all(win, lambda n: lowered in _safe_name(n).lower())
|
|
386
|
+
if substr:
|
|
387
|
+
return substr[0]
|
|
388
|
+
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
_INVOKE_ACTION_NAMES = ("click", "press", "activate", "jump")
|
|
393
|
+
_EXPAND_ACTION_NAMES = ("expand", "open", "click", "activate")
|
|
394
|
+
_TOGGLE_ACTION_NAMES = ("click", "toggle")
|
|
395
|
+
|
|
396
|
+
# pywinauto's special-key names ({ENTER}, {TAB}, ...) -> the names press()/key_funcs expects.
|
|
397
|
+
_SPECIAL_KEY_MAP = {
|
|
398
|
+
"ENTER": "enter", "TAB": "tab", "ESC": "esc", "ESCAPE": "esc",
|
|
399
|
+
"BACKSPACE": "backspace", "BKSP": "backspace", "DELETE": "delete", "DEL": "delete",
|
|
400
|
+
"HOME": "home", "END": "end", "INSERT": "insert", "INS": "insert",
|
|
401
|
+
"UP": "up", "DOWN": "down", "LEFT": "left", "RIGHT": "right",
|
|
402
|
+
"PGUP": "pageup", "PGDN": "pagedown", "SPACE": "space",
|
|
403
|
+
**{f"F{i}": f"f{i}" for i in range(1, 13)},
|
|
404
|
+
}
|
|
405
|
+
_MOD_CHARS = {"^": "ctrl", "%": "alt", "+": "shift"}
|
|
406
|
+
|
|
407
|
+
_ROLE_TO_CONTROL_TYPE_CACHE = {}
|
|
408
|
+
|
|
409
|
+
_STATE_WORD_TO_ATSPI = {
|
|
410
|
+
"visible": ("SHOWING",),
|
|
411
|
+
"enabled": ("ENABLED", "SENSITIVE"),
|
|
412
|
+
"active": ("ACTIVE", "FOCUSED"),
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _action_names(Atspi, acc) -> list:
|
|
417
|
+
try:
|
|
418
|
+
n = acc.get_n_actions()
|
|
419
|
+
except Exception: # pylint: disable=broad-except
|
|
420
|
+
return []
|
|
421
|
+
names = []
|
|
422
|
+
for i in range(n):
|
|
423
|
+
try:
|
|
424
|
+
with warnings.catch_warnings():
|
|
425
|
+
warnings.simplefilter("ignore", DeprecationWarning)
|
|
426
|
+
names.append(Atspi.Action.get_action_name(acc, i) or "")
|
|
427
|
+
except Exception: # pylint: disable=broad-except
|
|
428
|
+
names.append("")
|
|
429
|
+
return names
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _do_action_named(Atspi, acc, preferred_names, default_index=0) -> bool:
|
|
433
|
+
"""Run the first action matching one of preferred_names (case-insensitive), else
|
|
434
|
+
default_index if it exists at all. Returns whether an action actually ran."""
|
|
435
|
+
names = _action_names(Atspi, acc)
|
|
436
|
+
if not names:
|
|
437
|
+
return False
|
|
438
|
+
lowered = [n.lower() for n in names]
|
|
439
|
+
for wanted in preferred_names:
|
|
440
|
+
if wanted in lowered:
|
|
441
|
+
Atspi.Action.do_action(acc, lowered.index(wanted))
|
|
442
|
+
return True
|
|
443
|
+
if 0 <= default_index < len(names):
|
|
444
|
+
Atspi.Action.do_action(acc, default_index)
|
|
445
|
+
return True
|
|
446
|
+
return False
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _invoke(Atspi, ctrl):
|
|
450
|
+
if not _do_action_named(Atspi, ctrl, _INVOKE_ACTION_NAMES):
|
|
451
|
+
raise ValueError(
|
|
452
|
+
f"{_safe_name(ctrl)!r} ({_safe_role_name(ctrl)}) has no AT-SPI action to invoke."
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _grab_focus(Atspi, ctrl):
|
|
457
|
+
"""Component.grab_focus() first; if it fails, fall back to a real mouse click on the
|
|
458
|
+
control's extents (works on X11/Xwayland; same Wayland caveat as click_control).
|
|
459
|
+
|
|
460
|
+
Needed in practice, not just in theory: confirmed `grab_focus()` fails app-wide on
|
|
461
|
+
gnome-text-editor (GTK4) - even on a plain button, not just the text widget - with a
|
|
462
|
+
bare `atspi_error` and no further detail. Rather than treat that as fatal, fall back
|
|
463
|
+
to establishing focus the same way a real user would: by clicking it."""
|
|
464
|
+
try:
|
|
465
|
+
if ctrl.grab_focus():
|
|
466
|
+
return
|
|
467
|
+
except Exception: # pylint: disable=broad-except
|
|
468
|
+
pass
|
|
469
|
+
try:
|
|
470
|
+
_click_at_extents(Atspi, ctrl, "left", False)
|
|
471
|
+
except Exception as err: # pylint: disable=broad-except
|
|
472
|
+
raise ValueError(
|
|
473
|
+
f"Could not focus {_safe_name(ctrl)!r} ({_safe_role_name(ctrl)}): "
|
|
474
|
+
f"grab_focus() failed and the click-to-focus fallback also failed ({err})."
|
|
475
|
+
) from err
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _build_role_to_control_type(Atspi) -> dict:
|
|
479
|
+
mapping = {}
|
|
480
|
+
for ctype, role_names in _CONTROL_TYPE_TO_ROLES.items():
|
|
481
|
+
for name in role_names:
|
|
482
|
+
role = getattr(Atspi.Role, name, None)
|
|
483
|
+
if role is not None and role not in mapping:
|
|
484
|
+
mapping[role] = ctype
|
|
485
|
+
return mapping
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _role_to_control_type(Atspi, node):
|
|
489
|
+
if not _ROLE_TO_CONTROL_TYPE_CACHE:
|
|
490
|
+
_ROLE_TO_CONTROL_TYPE_CACHE.update(_build_role_to_control_type(Atspi))
|
|
491
|
+
try:
|
|
492
|
+
return _ROLE_TO_CONTROL_TYPE_CACHE.get(node.get_role())
|
|
493
|
+
except Exception: # pylint: disable=broad-except
|
|
494
|
+
return None
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _send_key_syntax(text: str, pause: float):
|
|
498
|
+
"""Translate pywinauto key syntax ({ENTER}, ^a, %f, +x, ~, parens groups) to key
|
|
499
|
+
presses via key_funcs. Not a full pywinauto-grammar implementation (no repeat
|
|
500
|
+
counts like "{TAB 3}", no nested groups) - covers the documented cases."""
|
|
501
|
+
i, n = 0, len(text)
|
|
502
|
+
while i < n:
|
|
503
|
+
ch = text[i]
|
|
504
|
+
if ch in _MOD_CHARS:
|
|
505
|
+
mods = []
|
|
506
|
+
while i < n and text[i] in _MOD_CHARS:
|
|
507
|
+
mods.append(_MOD_CHARS[text[i]])
|
|
508
|
+
i += 1
|
|
509
|
+
if i >= n:
|
|
510
|
+
break
|
|
511
|
+
if text[i] == "(":
|
|
512
|
+
j = text.index(")", i)
|
|
513
|
+
group, i = text[i + 1:j], j + 1
|
|
514
|
+
for gch in group:
|
|
515
|
+
keys2(*mods, gch)
|
|
516
|
+
if pause:
|
|
517
|
+
time.sleep(pause)
|
|
518
|
+
elif text[i] == "{":
|
|
519
|
+
j = text.index("}", i)
|
|
520
|
+
name, i = text[i + 1:j], j + 1
|
|
521
|
+
keys2(*mods, _SPECIAL_KEY_MAP.get(name.upper(), name.lower()))
|
|
522
|
+
else:
|
|
523
|
+
keys2(*mods, text[i])
|
|
524
|
+
i += 1
|
|
525
|
+
if pause:
|
|
526
|
+
time.sleep(pause)
|
|
527
|
+
elif ch == "{":
|
|
528
|
+
j = text.index("}", i)
|
|
529
|
+
name, i = text[i + 1:j], j + 1
|
|
530
|
+
press(_SPECIAL_KEY_MAP.get(name.upper(), name.lower()))
|
|
531
|
+
if pause:
|
|
532
|
+
time.sleep(pause)
|
|
533
|
+
elif ch == "~":
|
|
534
|
+
press("enter")
|
|
535
|
+
i += 1
|
|
536
|
+
if pause:
|
|
537
|
+
time.sleep(pause)
|
|
538
|
+
elif ch == "(":
|
|
539
|
+
j = text.index(")", i)
|
|
540
|
+
write(text[i + 1:j], interval=pause or 0.01)
|
|
541
|
+
i = j + 1
|
|
542
|
+
else:
|
|
543
|
+
j = i
|
|
544
|
+
while j < n and text[j] not in _MOD_CHARS and text[j] not in "{}~()":
|
|
545
|
+
j += 1
|
|
546
|
+
write(text[i:j], interval=pause or 0.01)
|
|
547
|
+
i = j
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _is_wayland_session() -> bool:
|
|
551
|
+
"""Coarse, session-wide check (not per-window) - good enough for the documented
|
|
552
|
+
"Wayland: screen coordinates / mouse clicks don't work" limitation."""
|
|
553
|
+
return bool(os.environ.get("WAYLAND_DISPLAY")) and os.environ.get("XDG_SESSION_TYPE", "").lower() != "x11"
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _click_at_extents(Atspi, ctrl, button: str, double: bool):
|
|
557
|
+
"""Mouse click at the control's screen extents (works on X11/Xwayland). On a Wayland
|
|
558
|
+
session this is unreliable (no arbitrary pointer warping) - callers should check
|
|
559
|
+
_is_wayland_session() first and fall back to _invoke()."""
|
|
560
|
+
try:
|
|
561
|
+
extents = ctrl.get_extents(Atspi.CoordType.SCREEN)
|
|
562
|
+
except Exception as err: # pylint: disable=broad-except
|
|
563
|
+
raise ValueError(
|
|
564
|
+
f"Could not get screen extents of {_safe_name(ctrl)!r} ({_safe_role_name(ctrl)}): {err}. "
|
|
565
|
+
"Try click_control(invoke=True) instead."
|
|
566
|
+
) from err
|
|
567
|
+
x, y = extents.x + extents.width // 2, extents.y + extents.height // 2
|
|
568
|
+
clicks = 2 if double else 1
|
|
569
|
+
pyautogui.click(x, y, clicks=clicks, button=button)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
_MENU_ROLE_NAMES = ("MENU", "MENU_ITEM", "CHECK_MENU_ITEM", "RADIO_MENU_ITEM", "POPUP_MENU", "TEAROFF_MENU_ITEM")
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _find_menu_item(Atspi, root, name: str):
|
|
576
|
+
target = name.replace("&", "").strip().lower()
|
|
577
|
+
roles = tuple(getattr(Atspi.Role, n) for n in _MENU_ROLE_NAMES if hasattr(Atspi.Role, n))
|
|
578
|
+
|
|
579
|
+
def matches(node):
|
|
580
|
+
return node.get_role() in roles and _safe_name(node).replace("&", "").strip().lower() == target
|
|
581
|
+
|
|
582
|
+
found = _find_all(root, matches)
|
|
583
|
+
return found[0] if found else None
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _expand_menu(Atspi, item):
|
|
587
|
+
_do_action_named(Atspi, item, _EXPAND_ACTION_NAMES)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _state_satisfied(Atspi, ctrl, words) -> bool:
|
|
591
|
+
try:
|
|
592
|
+
states = ctrl.get_state_set()
|
|
593
|
+
except Exception: # pylint: disable=broad-except
|
|
594
|
+
return False
|
|
595
|
+
for word in words:
|
|
596
|
+
wanted = _STATE_WORD_TO_ATSPI.get(word)
|
|
597
|
+
if wanted is None:
|
|
598
|
+
continue # "exists"/"ready": having ctrl already covers "exists" and
|
|
599
|
+
# "ready" is expanded into "visible enabled" by the caller.
|
|
600
|
+
if not any(states.contains(getattr(Atspi.StateType, w)) for w in wanted if hasattr(Atspi.StateType, w)):
|
|
601
|
+
return False
|
|
602
|
+
return True
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def get_window(window, backend: str = "atspi", timeout: float = 10):
|
|
606
|
+
"""Find a top-level window and wait until it is visible.
|
|
607
|
+
|
|
608
|
+
Args:
|
|
609
|
+
window (str): title of the window, regex matched anywhere in the title.
|
|
610
|
+
An already-resolved Atspi.Accessible is returned as it is.
|
|
611
|
+
backend (str, optional): "atspi". Defaults to "atspi".
|
|
612
|
+
timeout (float, optional): seconds to wait for the window. Defaults to 10.
|
|
613
|
+
|
|
614
|
+
Returns:
|
|
615
|
+
Atspi.Accessible: the top-level window (frame/dialog), for anything not
|
|
616
|
+
covered by these functions.
|
|
617
|
+
|
|
618
|
+
Raises:
|
|
619
|
+
LookupError: if no matching, visible window is found within timeout.
|
|
620
|
+
"""
|
|
621
|
+
Atspi = _prepare(backend)
|
|
622
|
+
if not isinstance(window, str):
|
|
623
|
+
return window
|
|
624
|
+
|
|
625
|
+
pattern = f".*?{window}.*?"
|
|
626
|
+
deadline = time.monotonic() + timeout
|
|
627
|
+
seen = []
|
|
628
|
+
while True:
|
|
629
|
+
seen = []
|
|
630
|
+
for win in _iter_top_level_windows():
|
|
631
|
+
name = _safe_name(win)
|
|
632
|
+
seen.append(name)
|
|
633
|
+
if re.match(pattern, name) and _is_showing(Atspi, win):
|
|
634
|
+
return win
|
|
635
|
+
if time.monotonic() >= deadline:
|
|
636
|
+
break
|
|
637
|
+
time.sleep(0.2)
|
|
638
|
+
|
|
639
|
+
seen_unique = sorted({name for name in seen if name})
|
|
640
|
+
raise LookupError(
|
|
641
|
+
f"No visible window matching {window!r} found within {timeout}s. "
|
|
642
|
+
f"Windows seen: {seen_unique or '<none>'}"
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def get_control(window, control=None, backend: str = "atspi", timeout: float = 10):
|
|
647
|
+
"""Find a control inside a window and wait until it exists.
|
|
648
|
+
|
|
649
|
+
Args:
|
|
650
|
+
window (str): title of the window (regex) or an Atspi.Accessible.
|
|
651
|
+
control (str | dict, optional): best-match name or search criteria.
|
|
652
|
+
None returns the window itself.
|
|
653
|
+
backend (str, optional): "atspi". Defaults to "atspi".
|
|
654
|
+
timeout (float, optional): seconds to wait. Defaults to 10.
|
|
655
|
+
|
|
656
|
+
Returns:
|
|
657
|
+
Atspi.Accessible: the control.
|
|
658
|
+
|
|
659
|
+
Raises:
|
|
660
|
+
LookupError: if no matching control is found within timeout.
|
|
661
|
+
"""
|
|
662
|
+
Atspi = _prepare(backend)
|
|
663
|
+
win = get_window(window, backend, timeout)
|
|
664
|
+
if control is None:
|
|
665
|
+
return win
|
|
666
|
+
|
|
667
|
+
if isinstance(control, dict):
|
|
668
|
+
criteria = dict(control)
|
|
669
|
+
found_index = criteria.pop("found_index", 0)
|
|
670
|
+
control_type_roles = None
|
|
671
|
+
if "control_type" in criteria:
|
|
672
|
+
control_type_roles = _roles_for_control_type(Atspi, criteria.pop("control_type"))
|
|
673
|
+
|
|
674
|
+
deadline = time.monotonic() + timeout
|
|
675
|
+
while True:
|
|
676
|
+
if isinstance(control, dict):
|
|
677
|
+
matches = _find_all(win, lambda n: _matches_dict(n, criteria, control_type_roles))
|
|
678
|
+
if len(matches) > found_index:
|
|
679
|
+
return matches[found_index]
|
|
680
|
+
else:
|
|
681
|
+
match = _best_match(Atspi, win, control)
|
|
682
|
+
if match is not None:
|
|
683
|
+
return match
|
|
684
|
+
if time.monotonic() >= deadline:
|
|
685
|
+
break
|
|
686
|
+
time.sleep(0.2)
|
|
687
|
+
|
|
688
|
+
message = (
|
|
689
|
+
f"Control not found in window {_safe_name(win)!r}: {control!r}. "
|
|
690
|
+
f"Closest names seen: {_collect_names(win) or '<none>'}"
|
|
691
|
+
)
|
|
692
|
+
if _has_no_children(win):
|
|
693
|
+
message += " " + _QT_CHROME_ELECTRON_HINT
|
|
694
|
+
raise LookupError(message)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def start_app(command: str, backend: str = "atspi", timeout: float = 10):
|
|
698
|
+
"""Start an application and wait until it appears on the AT-SPI bus.
|
|
699
|
+
|
|
700
|
+
Args:
|
|
701
|
+
command (str): command line to start the application.
|
|
702
|
+
backend (str, optional): "atspi". Defaults to "atspi".
|
|
703
|
+
timeout (float, optional): seconds to wait for the app to be ready. Defaults to 10.
|
|
704
|
+
|
|
705
|
+
Returns:
|
|
706
|
+
Atspi.Accessible: the application.
|
|
707
|
+
|
|
708
|
+
Raises:
|
|
709
|
+
LookupError: if the app does not appear on the AT-SPI bus within timeout.
|
|
710
|
+
"""
|
|
711
|
+
Atspi = _prepare(backend)
|
|
712
|
+
args = shlex.split(command) if isinstance(command, str) else command
|
|
713
|
+
# Detached: inheriting the caller's stdin/stdout/stderr/session would make the app's
|
|
714
|
+
# own lifetime hold those pipes open - e.g. a caller piping/capturing a script's
|
|
715
|
+
# output would see it hang until the (possibly long-running, GUI) app exits, found
|
|
716
|
+
# while testing this against real gnome-calculator/gnome-text-editor.
|
|
717
|
+
proc = subprocess.Popen( # pylint: disable=consider-using-with
|
|
718
|
+
args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
719
|
+
start_new_session=True,
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
deadline = time.monotonic() + timeout
|
|
723
|
+
while True:
|
|
724
|
+
desktop = Atspi.get_desktop(0)
|
|
725
|
+
for i in range(desktop.get_child_count()):
|
|
726
|
+
try:
|
|
727
|
+
app = desktop.get_child_at_index(i)
|
|
728
|
+
if app is not None and app.get_process_id() == proc.pid:
|
|
729
|
+
return app
|
|
730
|
+
except Exception: # pylint: disable=broad-except
|
|
731
|
+
continue
|
|
732
|
+
if time.monotonic() >= deadline:
|
|
733
|
+
break
|
|
734
|
+
time.sleep(0.2)
|
|
735
|
+
|
|
736
|
+
raise LookupError(
|
|
737
|
+
f"{command!r} (pid {proc.pid}) did not appear on the AT-SPI bus within {timeout}s. "
|
|
738
|
+
"The app may not expose accessibility - see pywinauto_for_linux.md known limits."
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def click_control(window, control=None, button: str = "left", double: bool = False,
|
|
743
|
+
invoke: bool = False, backend: str = "atspi", timeout: float = 10):
|
|
744
|
+
"""Click a control.
|
|
745
|
+
|
|
746
|
+
Args:
|
|
747
|
+
window (str): title of the window (regex) or an Atspi.Accessible.
|
|
748
|
+
control (str | dict, optional): the control to click. None clicks the window.
|
|
749
|
+
button (str, optional): "left", "right" or "middle". Defaults to "left".
|
|
750
|
+
Only meaningful when invoke=False (X11; see pywinauto_for_linux.md known limits).
|
|
751
|
+
double (bool, optional): double click. Defaults to False.
|
|
752
|
+
invoke (bool, optional): run the control's AT-SPI "click"/"press"/"activate" action
|
|
753
|
+
instead of a mouse click. Works on Wayland too. Defaults to False.
|
|
754
|
+
backend (str, optional): "atspi". Defaults to "atspi".
|
|
755
|
+
timeout (float, optional): seconds to wait for the control. Defaults to 10.
|
|
756
|
+
"""
|
|
757
|
+
Atspi = _prepare(backend)
|
|
758
|
+
ctrl = get_control(window, control, backend, timeout)
|
|
759
|
+
if invoke:
|
|
760
|
+
_invoke(Atspi, ctrl)
|
|
761
|
+
return
|
|
762
|
+
if _is_wayland_session():
|
|
763
|
+
warnings.warn(
|
|
764
|
+
"click_control(invoke=False) needs screen coordinates, which don't work "
|
|
765
|
+
"reliably on Wayland; falling back to invoke=True (the AT-SPI action) instead.",
|
|
766
|
+
stacklevel=2,
|
|
767
|
+
)
|
|
768
|
+
_invoke(Atspi, ctrl)
|
|
769
|
+
return
|
|
770
|
+
_click_at_extents(Atspi, ctrl, button, double)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def double_click_control(window, control=None, backend: str = "atspi", timeout: float = 10):
|
|
774
|
+
"""Double click a control. See click_control."""
|
|
775
|
+
click_control(window, control, double=True, backend=backend, timeout=timeout)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def right_click_control(window, control=None, backend: str = "atspi", timeout: float = 10):
|
|
779
|
+
"""Right click a control. See click_control."""
|
|
780
|
+
click_control(window, control, button="right", backend=backend, timeout=timeout)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def type_in_control(window, control=None, text: str = "", literal: bool = True, pause: float = None,
|
|
784
|
+
backend: str = "atspi", timeout: float = 10):
|
|
785
|
+
"""Grab focus on a control and type text into it with simulated key presses.
|
|
786
|
+
|
|
787
|
+
Args:
|
|
788
|
+
window (str): title of the window (regex) or an Atspi.Accessible.
|
|
789
|
+
control (str | dict, optional): the control to type into. None types into the window.
|
|
790
|
+
text (str): the text to type.
|
|
791
|
+
literal (bool, optional): type the text as it is. If False the text uses
|
|
792
|
+
pywinauto key syntax: {ENTER}, {TAB}, ^a (ctrl+a), %f (alt+f), +x (shift+x),
|
|
793
|
+
translated to the keys2 hotkey syntax. Defaults to True.
|
|
794
|
+
pause (float, optional): seconds between key presses. Defaults to None.
|
|
795
|
+
backend (str, optional): "atspi". Defaults to "atspi".
|
|
796
|
+
timeout (float, optional): seconds to wait for the control. Defaults to 10.
|
|
797
|
+
"""
|
|
798
|
+
Atspi = _prepare(backend)
|
|
799
|
+
ctrl = get_control(window, control, backend, timeout)
|
|
800
|
+
_grab_focus(Atspi, ctrl)
|
|
801
|
+
if literal:
|
|
802
|
+
write(text, interval=pause if pause is not None else 0.01)
|
|
803
|
+
else:
|
|
804
|
+
_send_key_syntax(text, pause or 0.0)
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def set_control_text(window, control=None, text: str = "", backend: str = "atspi", timeout: float = 10):
|
|
808
|
+
"""Replace the text of an edit control directly, without typing.
|
|
809
|
+
|
|
810
|
+
Args:
|
|
811
|
+
window (str): title of the window (regex) or an Atspi.Accessible.
|
|
812
|
+
control (str | dict, optional): the edit control.
|
|
813
|
+
text (str): the new text.
|
|
814
|
+
backend (str, optional): "atspi". Defaults to "atspi".
|
|
815
|
+
timeout (float, optional): seconds to wait for the control. Defaults to 10.
|
|
816
|
+
"""
|
|
817
|
+
Atspi = _prepare(backend)
|
|
818
|
+
ctrl = get_control(window, control, backend, timeout)
|
|
819
|
+
try:
|
|
820
|
+
ok = ctrl.set_text_contents(text)
|
|
821
|
+
except Exception: # pylint: disable=broad-except
|
|
822
|
+
ok = False
|
|
823
|
+
if ok:
|
|
824
|
+
return
|
|
825
|
+
# EditableText not supported (or failed): fall back to select-all + type over it.
|
|
826
|
+
_grab_focus(Atspi, ctrl)
|
|
827
|
+
keys2("ctrl", "a")
|
|
828
|
+
write(text)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def get_control_text(window, control=None, backend: str = "atspi", timeout: float = 10) -> str:
|
|
832
|
+
"""Read the text of a control. The text is also copied to the clipboard.
|
|
833
|
+
|
|
834
|
+
Args:
|
|
835
|
+
window (str): title of the window (regex) or an Atspi.Accessible.
|
|
836
|
+
control (str | dict, optional): the control. None reads the window title.
|
|
837
|
+
backend (str, optional): "atspi". Defaults to "atspi".
|
|
838
|
+
timeout (float, optional): seconds to wait for the control. Defaults to 10.
|
|
839
|
+
|
|
840
|
+
Returns:
|
|
841
|
+
str: the text of the control.
|
|
842
|
+
"""
|
|
843
|
+
Atspi = _prepare(backend)
|
|
844
|
+
ctrl = get_control(window, control, backend, timeout)
|
|
845
|
+
text = None
|
|
846
|
+
try:
|
|
847
|
+
if ctrl.is_text():
|
|
848
|
+
count = ctrl.get_character_count()
|
|
849
|
+
text = Atspi.Text.get_text(ctrl, 0, count) if count else ""
|
|
850
|
+
except Exception: # pylint: disable=broad-except
|
|
851
|
+
text = None
|
|
852
|
+
if text is None:
|
|
853
|
+
text = _safe_name(ctrl)
|
|
854
|
+
copy(text)
|
|
855
|
+
return text
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def select_menu(window, path: str, backend: str = "atspi", timeout: float = 10):
|
|
859
|
+
"""Select a menu item.
|
|
860
|
+
|
|
861
|
+
Args:
|
|
862
|
+
window (str): title of the window (regex) or an Atspi.Accessible.
|
|
863
|
+
path (str): menu path separated by "->", e.g. "File->Save As..."
|
|
864
|
+
backend (str, optional): "atspi". Defaults to "atspi".
|
|
865
|
+
timeout (float, optional): seconds to wait for the window. Defaults to 10.
|
|
866
|
+
"""
|
|
867
|
+
Atspi = _prepare(backend)
|
|
868
|
+
win = get_window(window, backend, timeout)
|
|
869
|
+
names = [part.strip() for part in path.split("->")]
|
|
870
|
+
node = win
|
|
871
|
+
for i, name in enumerate(names):
|
|
872
|
+
item = _find_menu_item(Atspi, node, name)
|
|
873
|
+
if item is None:
|
|
874
|
+
message = (
|
|
875
|
+
f"Menu item not found: {name!r} (from path {path!r}) in window "
|
|
876
|
+
f"{_safe_name(win)!r}. Closest names seen: {_collect_names(win) or '<none>'}"
|
|
877
|
+
)
|
|
878
|
+
if _has_no_children(win):
|
|
879
|
+
message += " " + _QT_CHROME_ELECTRON_HINT
|
|
880
|
+
raise LookupError(message)
|
|
881
|
+
if i < len(names) - 1:
|
|
882
|
+
_expand_menu(Atspi, item) # best effort; GTK pre-populates the tree anyway
|
|
883
|
+
node = item
|
|
884
|
+
else:
|
|
885
|
+
_invoke(Atspi, item)
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def select_item(window, control, item, backend: str = "atspi", timeout: float = 10):
|
|
889
|
+
"""Select an item in a combo box, list box, list view or tab control.
|
|
890
|
+
|
|
891
|
+
Args:
|
|
892
|
+
window (str): title of the window (regex) or an Atspi.Accessible.
|
|
893
|
+
control (str | dict): the combo box / list control.
|
|
894
|
+
item (str | int): text or index of the item.
|
|
895
|
+
backend (str, optional): "atspi". Defaults to "atspi".
|
|
896
|
+
timeout (float, optional): seconds to wait for the control. Defaults to 10.
|
|
897
|
+
"""
|
|
898
|
+
Atspi = _prepare(backend)
|
|
899
|
+
ctrl = get_control(window, control, backend, timeout)
|
|
900
|
+
|
|
901
|
+
# A GTK combo box's selectable items live inside a nested popup with role "menu";
|
|
902
|
+
# other Selection-capable widgets (list, tabs) expose them directly as children -
|
|
903
|
+
# see pywinauto_for_linux.md section 1 findings.
|
|
904
|
+
container = ctrl
|
|
905
|
+
try:
|
|
906
|
+
if ctrl.get_role() == Atspi.Role.COMBO_BOX and ctrl.get_child_count() == 1:
|
|
907
|
+
popup = ctrl.get_child_at_index(0)
|
|
908
|
+
if popup is not None:
|
|
909
|
+
container = popup
|
|
910
|
+
except Exception: # pylint: disable=broad-except
|
|
911
|
+
pass
|
|
912
|
+
|
|
913
|
+
try:
|
|
914
|
+
n = container.get_child_count()
|
|
915
|
+
except Exception: # pylint: disable=broad-except
|
|
916
|
+
n = 0
|
|
917
|
+
|
|
918
|
+
target = None
|
|
919
|
+
if isinstance(item, int):
|
|
920
|
+
if 0 <= item < n:
|
|
921
|
+
try:
|
|
922
|
+
target = container.get_child_at_index(item)
|
|
923
|
+
except Exception: # pylint: disable=broad-except
|
|
924
|
+
target = None
|
|
925
|
+
else:
|
|
926
|
+
for i in range(n):
|
|
927
|
+
try:
|
|
928
|
+
child = container.get_child_at_index(i)
|
|
929
|
+
except Exception: # pylint: disable=broad-except
|
|
930
|
+
continue
|
|
931
|
+
if _safe_name(child) == item:
|
|
932
|
+
target = child
|
|
933
|
+
break
|
|
934
|
+
|
|
935
|
+
if target is None:
|
|
936
|
+
seen = [_safe_name(container.get_child_at_index(i)) for i in range(n)]
|
|
937
|
+
raise LookupError(
|
|
938
|
+
f"Item not found in {_safe_name(ctrl)!r} ({_safe_role_name(ctrl)}): {item!r}. "
|
|
939
|
+
f"Items seen: {seen or '<none>'}"
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
# Clicking the item directly is what actually commits the selection for a GTK combo
|
|
943
|
+
# box - Atspi.Selection.select_child() on the popup does not (section 1 finding).
|
|
944
|
+
# Try that first; fall back to the Selection interface for widgets where it's correct.
|
|
945
|
+
if _do_action_named(Atspi, target, ("click", "select")):
|
|
946
|
+
return
|
|
947
|
+
try:
|
|
948
|
+
Atspi.Selection.select_child(container, target.get_index_in_parent())
|
|
949
|
+
except Exception as err: # pylint: disable=broad-except
|
|
950
|
+
raise ValueError(
|
|
951
|
+
f"Could not select {item!r} in {_safe_name(ctrl)!r}: no action to click it, "
|
|
952
|
+
f"and the Selection interface failed ({err})."
|
|
953
|
+
) from err
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def check_control(window, control, checked: bool = True, backend: str = "atspi", timeout: float = 10):
|
|
957
|
+
"""Check or uncheck a check box / toggle button.
|
|
958
|
+
|
|
959
|
+
Args:
|
|
960
|
+
window (str): title of the window (regex) or an Atspi.Accessible.
|
|
961
|
+
control (str | dict): the check box.
|
|
962
|
+
checked (bool, optional): wanted state. Defaults to True.
|
|
963
|
+
backend (str, optional): "atspi". Defaults to "atspi".
|
|
964
|
+
timeout (float, optional): seconds to wait for the control. Defaults to 10.
|
|
965
|
+
"""
|
|
966
|
+
Atspi = _prepare(backend)
|
|
967
|
+
ctrl = get_control(window, control, backend, timeout)
|
|
968
|
+
try:
|
|
969
|
+
is_checked = ctrl.get_state_set().contains(Atspi.StateType.CHECKED)
|
|
970
|
+
except Exception as err: # pylint: disable=broad-except
|
|
971
|
+
raise ValueError(
|
|
972
|
+
f"Could not read the checked state of {_safe_name(ctrl)!r} ({_safe_role_name(ctrl)}): {err}"
|
|
973
|
+
) from err
|
|
974
|
+
if is_checked == checked:
|
|
975
|
+
return
|
|
976
|
+
if not _do_action_named(Atspi, ctrl, _TOGGLE_ACTION_NAMES):
|
|
977
|
+
raise ValueError(
|
|
978
|
+
f"{_safe_name(ctrl)!r} ({_safe_role_name(ctrl)}) has no AT-SPI action to toggle it."
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def uncheck_control(window, control, backend: str = "atspi", timeout: float = 10):
|
|
983
|
+
"""Uncheck a check box / toggle button. See check_control."""
|
|
984
|
+
check_control(window, control, False, backend, timeout)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def wait_control(window, control=None, state: str = "exists visible",
|
|
988
|
+
backend: str = "atspi", timeout: float = 10):
|
|
989
|
+
"""Wait until a control reaches a state.
|
|
990
|
+
|
|
991
|
+
Args:
|
|
992
|
+
window (str): title of the window (regex) or an Atspi.Accessible.
|
|
993
|
+
control (str | dict, optional): the control. None waits for the window.
|
|
994
|
+
state (str, optional): space separated: exists, visible, enabled, ready, active.
|
|
995
|
+
Defaults to "exists visible".
|
|
996
|
+
backend (str, optional): "atspi". Defaults to "atspi".
|
|
997
|
+
timeout (float, optional): seconds to wait. Defaults to 10.
|
|
998
|
+
|
|
999
|
+
Raises:
|
|
1000
|
+
TimeoutError: if the state is not reached in time.
|
|
1001
|
+
"""
|
|
1002
|
+
Atspi = _prepare(backend)
|
|
1003
|
+
words = state.split()
|
|
1004
|
+
if "ready" in words:
|
|
1005
|
+
words = words + ["visible", "enabled"]
|
|
1006
|
+
|
|
1007
|
+
deadline = time.monotonic() + timeout
|
|
1008
|
+
last_err = None
|
|
1009
|
+
while True:
|
|
1010
|
+
try:
|
|
1011
|
+
ctrl = get_control(window, control, backend, 0)
|
|
1012
|
+
except LookupError as err:
|
|
1013
|
+
ctrl, last_err = None, err
|
|
1014
|
+
if ctrl is not None and _state_satisfied(Atspi, ctrl, words):
|
|
1015
|
+
return ctrl
|
|
1016
|
+
if time.monotonic() >= deadline:
|
|
1017
|
+
break
|
|
1018
|
+
time.sleep(0.2)
|
|
1019
|
+
|
|
1020
|
+
raise TimeoutError(
|
|
1021
|
+
f"Control did not reach state {state!r} within {timeout}s"
|
|
1022
|
+
+ (f" (last lookup error: {last_err})" if last_err else "")
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def control_exists(window, control=None, backend: str = "atspi", timeout: float = 0) -> bool:
|
|
1027
|
+
"""Check if a window / control exists.
|
|
1028
|
+
|
|
1029
|
+
Args:
|
|
1030
|
+
window (str): title of the window (regex).
|
|
1031
|
+
control (str | dict, optional): the control. None checks only the window.
|
|
1032
|
+
backend (str, optional): "atspi". Defaults to "atspi".
|
|
1033
|
+
timeout (float, optional): seconds to keep looking. Defaults to 0.
|
|
1034
|
+
|
|
1035
|
+
Returns:
|
|
1036
|
+
bool: True if found.
|
|
1037
|
+
"""
|
|
1038
|
+
_prepare(backend)
|
|
1039
|
+
try:
|
|
1040
|
+
get_control(window, control, backend, timeout)
|
|
1041
|
+
except LookupError:
|
|
1042
|
+
return False
|
|
1043
|
+
return True
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def list_controls(window, depth: int = None, backend: str = "atspi", timeout: float = 10) -> str:
|
|
1047
|
+
"""Print the controls of a window with the names usable as `control`.
|
|
1048
|
+
|
|
1049
|
+
Args:
|
|
1050
|
+
window (str): title of the window (regex) or an Atspi.Accessible.
|
|
1051
|
+
depth (int, optional): how many levels deep to go. Defaults to all.
|
|
1052
|
+
backend (str, optional): "atspi". Defaults to "atspi".
|
|
1053
|
+
timeout (float, optional): seconds to wait for the window. Defaults to 10.
|
|
1054
|
+
|
|
1055
|
+
Returns:
|
|
1056
|
+
str: the printed control tree.
|
|
1057
|
+
"""
|
|
1058
|
+
Atspi = _prepare(backend)
|
|
1059
|
+
win = get_window(window, backend, timeout)
|
|
1060
|
+
lines = []
|
|
1061
|
+
|
|
1062
|
+
def visit(node, indent):
|
|
1063
|
+
name = _safe_name(node)
|
|
1064
|
+
criteria = {}
|
|
1065
|
+
if name:
|
|
1066
|
+
criteria["title"] = name
|
|
1067
|
+
control_type = _role_to_control_type(Atspi, node)
|
|
1068
|
+
if control_type:
|
|
1069
|
+
criteria["control_type"] = control_type
|
|
1070
|
+
acc_id = _accessible_id(node)
|
|
1071
|
+
if acc_id:
|
|
1072
|
+
criteria["auto_id"] = acc_id
|
|
1073
|
+
lines.append(f"{' ' * indent}{_safe_role_name(node)} {name!r} control={criteria!r}")
|
|
1074
|
+
if depth is not None and indent >= depth:
|
|
1075
|
+
return
|
|
1076
|
+
try:
|
|
1077
|
+
n = node.get_child_count()
|
|
1078
|
+
except Exception: # pylint: disable=broad-except
|
|
1079
|
+
return
|
|
1080
|
+
for i in range(n):
|
|
1081
|
+
try:
|
|
1082
|
+
child = node.get_child_at_index(i)
|
|
1083
|
+
except Exception: # pylint: disable=broad-except
|
|
1084
|
+
continue
|
|
1085
|
+
if child is not None:
|
|
1086
|
+
visit(child, indent + 1)
|
|
1087
|
+
|
|
1088
|
+
visit(win, 0)
|
|
1089
|
+
if _has_no_children(win):
|
|
1090
|
+
lines.append(_QT_CHROME_ELECTRON_HINT)
|
|
1091
|
+
tree = "\n".join(lines)
|
|
1092
|
+
print(tree)
|
|
1093
|
+
return tree
|