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.
@@ -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