robotframework-desktop 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- DesktopLibrary/__init__.py +278 -0
- DesktopLibrary/context.py +272 -0
- DesktopLibrary/cursor.py +243 -0
- DesktopLibrary/engines/__init__.py +4 -0
- DesktopLibrary/engines/image.py +174 -0
- DesktopLibrary/engines/ui.py +192 -0
- DesktopLibrary/geometry.py +187 -0
- DesktopLibrary/keywords/__init__.py +23 -0
- DesktopLibrary/keywords/application.py +149 -0
- DesktopLibrary/keywords/base.py +90 -0
- DesktopLibrary/keywords/config.py +159 -0
- DesktopLibrary/keywords/cursor.py +201 -0
- DesktopLibrary/keywords/element.py +260 -0
- DesktopLibrary/keywords/find.py +136 -0
- DesktopLibrary/keywords/keyboard.py +171 -0
- DesktopLibrary/keywords/mouse.py +357 -0
- DesktopLibrary/keywords/ocr.py +166 -0
- DesktopLibrary/keywords/region.py +288 -0
- DesktopLibrary/keywords/wait.py +178 -0
- DesktopLibrary/locator.py +223 -0
- DesktopLibrary/overlay.py +180 -0
- robotframework_desktop-0.1.0.dist-info/METADATA +215 -0
- robotframework_desktop-0.1.0.dist-info/RECORD +25 -0
- robotframework_desktop-0.1.0.dist-info/WHEEL +5 -0
- robotframework_desktop-0.1.0.dist-info/top_level.txt +1 -0
DesktopLibrary/cursor.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Mouse cursor inspection (Windows ``GetCursorInfo``) and busy-cursor waiting."""
|
|
2
|
+
|
|
3
|
+
import ctypes
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from ctypes import wintypes
|
|
8
|
+
|
|
9
|
+
CURSOR_IDS = {
|
|
10
|
+
'arrow': 32512,
|
|
11
|
+
'ibeam': 32513,
|
|
12
|
+
'wait': 32514,
|
|
13
|
+
'cross': 32515,
|
|
14
|
+
'uparrow': 32516,
|
|
15
|
+
'sizenwse': 32642,
|
|
16
|
+
'sizenesw': 32643,
|
|
17
|
+
'sizewe': 32644,
|
|
18
|
+
'sizens': 32645,
|
|
19
|
+
'sizeall': 32646,
|
|
20
|
+
'no': 32648,
|
|
21
|
+
'hand': 32649,
|
|
22
|
+
'appstarting': 32650,
|
|
23
|
+
'help': 32651,
|
|
24
|
+
}
|
|
25
|
+
ID_TO_NAME = {v: k for k, v in CURSOR_IDS.items()}
|
|
26
|
+
SPECIAL_TYPES = ('custom', 'none')
|
|
27
|
+
_CURSOR_SHOWING = 0x00000001
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BusyCursorTimeout(AssertionError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _POINT(ctypes.Structure):
|
|
35
|
+
_fields_ = [('x', wintypes.LONG), ('y', wintypes.LONG)]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _CURSORINFO(ctypes.Structure):
|
|
39
|
+
_fields_ = [
|
|
40
|
+
('cbSize', wintypes.DWORD),
|
|
41
|
+
('flags', wintypes.DWORD),
|
|
42
|
+
('hCursor', ctypes.c_void_p),
|
|
43
|
+
('ptScreenPos', _POINT),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def normalize_cursor_types(value) -> list[str]:
|
|
48
|
+
"""Accepts a name, an id, a comma separated string or a list."""
|
|
49
|
+
if isinstance(value, (list, tuple, set)):
|
|
50
|
+
items = list(value)
|
|
51
|
+
elif isinstance(value, str):
|
|
52
|
+
items = [v for v in value.split(',')]
|
|
53
|
+
else:
|
|
54
|
+
items = [value]
|
|
55
|
+
result = []
|
|
56
|
+
for item in items:
|
|
57
|
+
text = str(item).strip().lower()
|
|
58
|
+
if not text:
|
|
59
|
+
continue
|
|
60
|
+
if text.isdigit() and int(text) in ID_TO_NAME:
|
|
61
|
+
text = ID_TO_NAME[int(text)]
|
|
62
|
+
if text not in CURSOR_IDS and text not in SPECIAL_TYPES:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"Invalid cursor type '{item}'. Valid: "
|
|
65
|
+
f"{', '.join([*CURSOR_IDS, *SPECIAL_TYPES])} or their numeric ids."
|
|
66
|
+
)
|
|
67
|
+
result.append(text)
|
|
68
|
+
if not result:
|
|
69
|
+
raise ValueError('At least one cursor type is required.')
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CursorMonitor:
|
|
74
|
+
"""Reads the current cursor type and waits for it to stop being busy."""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
enabled=True,
|
|
79
|
+
timeout=30.0,
|
|
80
|
+
stable_duration=0.5,
|
|
81
|
+
busy_types=('wait', 'appstarting'),
|
|
82
|
+
fail_on_timeout=True,
|
|
83
|
+
poll_interval=0.05,
|
|
84
|
+
reader=None,
|
|
85
|
+
logger=None,
|
|
86
|
+
after_delay=0.2,
|
|
87
|
+
):
|
|
88
|
+
self.enabled = enabled
|
|
89
|
+
self.timeout = float(timeout)
|
|
90
|
+
self.stable_duration = float(stable_duration)
|
|
91
|
+
self.busy_types = normalize_cursor_types(list(busy_types))
|
|
92
|
+
self.fail_on_timeout = fail_on_timeout
|
|
93
|
+
self.after_delay = float(after_delay)
|
|
94
|
+
self.poll_interval = poll_interval
|
|
95
|
+
self._reader = reader
|
|
96
|
+
self._log = logger or (lambda msg, level='INFO': None)
|
|
97
|
+
self._handles: dict[int, str] | None = None
|
|
98
|
+
self._ignore_errors = 0
|
|
99
|
+
|
|
100
|
+
@contextmanager
|
|
101
|
+
def action(self, ignore_errors: bool = False):
|
|
102
|
+
"""Wraps a keyword action: errors become warnings when ``ignore_errors``
|
|
103
|
+
and the busy cursor is awaited after the action completes."""
|
|
104
|
+
if ignore_errors:
|
|
105
|
+
self._ignore_errors += 1
|
|
106
|
+
try:
|
|
107
|
+
yield
|
|
108
|
+
self.after_action()
|
|
109
|
+
finally:
|
|
110
|
+
if ignore_errors:
|
|
111
|
+
self._ignore_errors -= 1
|
|
112
|
+
|
|
113
|
+
def after_action(self):
|
|
114
|
+
"""Watches ``after_delay`` seconds for a busy cursor caused by the
|
|
115
|
+
action and waits for it to finish."""
|
|
116
|
+
if not self.enabled:
|
|
117
|
+
return
|
|
118
|
+
deadline = time.monotonic() + self.after_delay
|
|
119
|
+
while True:
|
|
120
|
+
if self.current() in self.busy_types:
|
|
121
|
+
self._log(
|
|
122
|
+
'Busy cursor detected after action, waiting for the application.'
|
|
123
|
+
)
|
|
124
|
+
self.wait_not_busy()
|
|
125
|
+
return
|
|
126
|
+
if time.monotonic() >= deadline:
|
|
127
|
+
return
|
|
128
|
+
time.sleep(self.poll_interval)
|
|
129
|
+
|
|
130
|
+
# ---- reading ------------------------------------------------------------
|
|
131
|
+
def _system_handles(self) -> dict[int, str]:
|
|
132
|
+
if self._handles is None:
|
|
133
|
+
user32 = ctypes.WinDLL('user32', use_last_error=True)
|
|
134
|
+
user32.LoadCursorW.argtypes = (wintypes.HINSTANCE, ctypes.c_void_p)
|
|
135
|
+
user32.LoadCursorW.restype = ctypes.c_void_p
|
|
136
|
+
self._handles = {}
|
|
137
|
+
for name, idc in CURSOR_IDS.items():
|
|
138
|
+
handle = user32.LoadCursorW(None, ctypes.c_void_p(idc))
|
|
139
|
+
if handle:
|
|
140
|
+
self._handles[int(handle)] = name
|
|
141
|
+
return self._handles
|
|
142
|
+
|
|
143
|
+
def current(self) -> str:
|
|
144
|
+
"""Returns the cursor type name, ``custom`` or ``none``."""
|
|
145
|
+
if self._reader is not None:
|
|
146
|
+
return self._reader()
|
|
147
|
+
if sys.platform != 'win32':
|
|
148
|
+
return 'none'
|
|
149
|
+
info = _CURSORINFO()
|
|
150
|
+
info.cbSize = ctypes.sizeof(_CURSORINFO)
|
|
151
|
+
user32 = ctypes.WinDLL('user32', use_last_error=True)
|
|
152
|
+
if not user32.GetCursorInfo(ctypes.byref(info)):
|
|
153
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
154
|
+
if not info.flags & _CURSOR_SHOWING or not info.hCursor:
|
|
155
|
+
return 'none'
|
|
156
|
+
return self._system_handles().get(int(info.hCursor), 'custom')
|
|
157
|
+
|
|
158
|
+
def is_busy(self, cursor: str | None = None) -> bool:
|
|
159
|
+
return (cursor or self.current()) in self.busy_types
|
|
160
|
+
|
|
161
|
+
# ---- waiting ------------------------------------------------------------
|
|
162
|
+
def wait_until(
|
|
163
|
+
self, accept, timeout: float, stable_duration: float, description: str
|
|
164
|
+
) -> tuple[bool, str, list]:
|
|
165
|
+
"""Waits until ``accept(cursor)`` is true continuously for
|
|
166
|
+
``stable_duration``. Returns ``(ok, last_cursor, changes)``."""
|
|
167
|
+
start = time.monotonic()
|
|
168
|
+
deadline = start + timeout
|
|
169
|
+
last = None
|
|
170
|
+
stable_since = None
|
|
171
|
+
changes = []
|
|
172
|
+
while True:
|
|
173
|
+
now = time.monotonic()
|
|
174
|
+
cursor = self.current()
|
|
175
|
+
if cursor != last:
|
|
176
|
+
if last is not None:
|
|
177
|
+
changes.append((round(now - start, 2), last, cursor))
|
|
178
|
+
last = cursor
|
|
179
|
+
stable_since = now if accept(cursor) else None
|
|
180
|
+
elif stable_since is None and accept(cursor):
|
|
181
|
+
stable_since = now
|
|
182
|
+
if (
|
|
183
|
+
stable_since is not None
|
|
184
|
+
and now - stable_since >= stable_duration
|
|
185
|
+
):
|
|
186
|
+
return True, cursor, changes
|
|
187
|
+
if now >= deadline:
|
|
188
|
+
return False, cursor, changes
|
|
189
|
+
time.sleep(self.poll_interval)
|
|
190
|
+
|
|
191
|
+
def wait_not_busy(
|
|
192
|
+
self,
|
|
193
|
+
timeout: float | None = None,
|
|
194
|
+
stable_duration: float | None = None,
|
|
195
|
+
fail: bool | None = None,
|
|
196
|
+
) -> str:
|
|
197
|
+
timeout = self.timeout if timeout is None else float(timeout)
|
|
198
|
+
stable = (
|
|
199
|
+
self.stable_duration
|
|
200
|
+
if stable_duration is None
|
|
201
|
+
else float(stable_duration)
|
|
202
|
+
)
|
|
203
|
+
fail = self.fail_on_timeout if fail is None else fail
|
|
204
|
+
if self._ignore_errors:
|
|
205
|
+
fail = False
|
|
206
|
+
first = self.current()
|
|
207
|
+
if first not in self.busy_types and stable <= 0:
|
|
208
|
+
return first
|
|
209
|
+
ok, cursor, changes = self.wait_until(
|
|
210
|
+
lambda c: c not in self.busy_types, timeout, stable, 'not busy'
|
|
211
|
+
)
|
|
212
|
+
if changes:
|
|
213
|
+
self._log(
|
|
214
|
+
f'Cursor changes while waiting: {_format_changes(changes)}'
|
|
215
|
+
)
|
|
216
|
+
if ok:
|
|
217
|
+
return cursor
|
|
218
|
+
message = (
|
|
219
|
+
f"Cursor still busy after {timeout:g} seconds (current: '{cursor}', "
|
|
220
|
+
f"busy types: {', '.join(self.busy_types)}). Changes: {_format_changes(changes)}"
|
|
221
|
+
)
|
|
222
|
+
if fail:
|
|
223
|
+
raise BusyCursorTimeout(message)
|
|
224
|
+
self._log(message, 'WARN')
|
|
225
|
+
return cursor
|
|
226
|
+
|
|
227
|
+
def auto_wait(self, timeout: float | None = None):
|
|
228
|
+
"""Hook called by keywords before acting. No-op when disabled."""
|
|
229
|
+
if not self.enabled:
|
|
230
|
+
return
|
|
231
|
+
if timeout is not None:
|
|
232
|
+
timeout = min(timeout, self.timeout)
|
|
233
|
+
if self.current() not in self.busy_types:
|
|
234
|
+
# Quick path: only wait the stability window if the cursor is busy now.
|
|
235
|
+
return
|
|
236
|
+
self._log('Busy cursor detected, waiting for the application.')
|
|
237
|
+
self.wait_not_busy(timeout)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _format_changes(changes) -> str:
|
|
241
|
+
if not changes:
|
|
242
|
+
return 'none'
|
|
243
|
+
return ', '.join(f'{a}->{b} (t={t}s)' for t, a, b in changes)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import cv2
|
|
5
|
+
import mss
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pyautogui
|
|
8
|
+
from PIL import Image
|
|
9
|
+
|
|
10
|
+
from ..geometry import Match, Region
|
|
11
|
+
from ..locator import IMAGE_EXTENSIONS, Locator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ImagePathResolver:
|
|
15
|
+
"""Resolves relative image paths against the registered image folders."""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.paths: list[Path] = []
|
|
19
|
+
|
|
20
|
+
def add(self, path: str) -> None:
|
|
21
|
+
p = Path(os.path.expandvars(os.path.expanduser(str(path)))).resolve()
|
|
22
|
+
if p not in self.paths:
|
|
23
|
+
self.paths.append(p)
|
|
24
|
+
|
|
25
|
+
def remove(self, path: str) -> None:
|
|
26
|
+
p = Path(os.path.expandvars(os.path.expanduser(str(path)))).resolve()
|
|
27
|
+
if p in self.paths:
|
|
28
|
+
self.paths.remove(p)
|
|
29
|
+
|
|
30
|
+
def candidates(self, name: str) -> list[Path]:
|
|
31
|
+
raw = Path(os.path.expandvars(os.path.expanduser(name)))
|
|
32
|
+
names = [raw]
|
|
33
|
+
if raw.suffix.lower() not in IMAGE_EXTENSIONS:
|
|
34
|
+
names += [
|
|
35
|
+
raw.with_name(raw.name + ext) for ext in IMAGE_EXTENSIONS
|
|
36
|
+
]
|
|
37
|
+
result: list[Path] = []
|
|
38
|
+
for n in names:
|
|
39
|
+
if n.is_absolute():
|
|
40
|
+
result.append(n)
|
|
41
|
+
continue
|
|
42
|
+
result += [base / n for base in self.paths]
|
|
43
|
+
result.append(Path.cwd() / n)
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
def resolve(self, name: str) -> Path:
|
|
47
|
+
tried = self.candidates(str(name))
|
|
48
|
+
for c in tried:
|
|
49
|
+
if c.is_file():
|
|
50
|
+
return c.resolve()
|
|
51
|
+
searched = '\n '.join(str(t) for t in tried)
|
|
52
|
+
raise FileNotFoundError(
|
|
53
|
+
f"Image '{name}' not found. Searched:\n {searched}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ImageEngine:
|
|
58
|
+
"""Image recognition based on pyautogui (OpenCV backed ``confidence``)."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, resolver: ImagePathResolver):
|
|
61
|
+
self.resolver = resolver
|
|
62
|
+
self.grayscale = False
|
|
63
|
+
self.resize_factor = 0.0
|
|
64
|
+
self._cache: dict[tuple[str, float], Image.Image] = {}
|
|
65
|
+
|
|
66
|
+
# ---- capture -------------------------------------------------------
|
|
67
|
+
@staticmethod
|
|
68
|
+
def grab(region: Region) -> Image.Image:
|
|
69
|
+
with mss.mss() as sct:
|
|
70
|
+
shot = sct.grab(
|
|
71
|
+
{
|
|
72
|
+
'left': region.x,
|
|
73
|
+
'top': region.y,
|
|
74
|
+
'width': region.width,
|
|
75
|
+
'height': region.height,
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
return Image.frombytes('RGB', shot.size, shot.bgra, 'raw', 'BGRX')
|
|
79
|
+
|
|
80
|
+
# ---- needles -------------------------------------------------------
|
|
81
|
+
def load(self, name: str) -> tuple[Image.Image, Path]:
|
|
82
|
+
path = self.resolver.resolve(name)
|
|
83
|
+
key = (str(path), self.resize_factor, os.path.getmtime(path))
|
|
84
|
+
img = self._cache.get(key)
|
|
85
|
+
if img is None:
|
|
86
|
+
img = Image.open(path).convert('RGB')
|
|
87
|
+
if self.resize_factor and self.resize_factor not in (0, 1):
|
|
88
|
+
img = img.resize(
|
|
89
|
+
(
|
|
90
|
+
max(1, int(img.width * self.resize_factor)),
|
|
91
|
+
max(1, int(img.height * self.resize_factor)),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
self._cache[key] = img
|
|
95
|
+
return img, path
|
|
96
|
+
|
|
97
|
+
# ---- search --------------------------------------------------------
|
|
98
|
+
def find_all(
|
|
99
|
+
self,
|
|
100
|
+
locator: Locator,
|
|
101
|
+
search: Region,
|
|
102
|
+
similarity: float,
|
|
103
|
+
haystack: Image.Image | None = None,
|
|
104
|
+
) -> list[Match]:
|
|
105
|
+
if haystack is None:
|
|
106
|
+
haystack = self.grab(search)
|
|
107
|
+
matches: list[Match] = []
|
|
108
|
+
for name in locator.images:
|
|
109
|
+
needle, path = self.load(name)
|
|
110
|
+
if (
|
|
111
|
+
needle.width > haystack.width
|
|
112
|
+
or needle.height > haystack.height
|
|
113
|
+
):
|
|
114
|
+
continue
|
|
115
|
+
found: list[Match] = []
|
|
116
|
+
try:
|
|
117
|
+
boxes = list(
|
|
118
|
+
pyautogui.locateAll(
|
|
119
|
+
needle,
|
|
120
|
+
haystack,
|
|
121
|
+
confidence=similarity,
|
|
122
|
+
grayscale=self.grayscale,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
except Exception as err:
|
|
126
|
+
if type(err).__name__ != 'ImageNotFoundException':
|
|
127
|
+
raise
|
|
128
|
+
boxes = []
|
|
129
|
+
for box in boxes:
|
|
130
|
+
m = Match(
|
|
131
|
+
search.x + int(box.left),
|
|
132
|
+
search.y + int(box.top),
|
|
133
|
+
int(box.width),
|
|
134
|
+
int(box.height),
|
|
135
|
+
score=0.0,
|
|
136
|
+
source=str(path),
|
|
137
|
+
offset=tuple(locator.offset),
|
|
138
|
+
)
|
|
139
|
+
if any(m.overlap_ratio(o) > 0.5 for o in found):
|
|
140
|
+
continue
|
|
141
|
+
found.append(m)
|
|
142
|
+
for m in found:
|
|
143
|
+
crop = haystack.crop(
|
|
144
|
+
(
|
|
145
|
+
m.x - search.x,
|
|
146
|
+
m.y - search.y,
|
|
147
|
+
m.right - search.x,
|
|
148
|
+
m.bottom - search.y,
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
m.score = self._score(needle, crop)
|
|
152
|
+
matches.extend(found)
|
|
153
|
+
return matches
|
|
154
|
+
|
|
155
|
+
def best_score(self, locator: Locator, search: Region) -> float:
|
|
156
|
+
haystack = self.grab(search)
|
|
157
|
+
best = 0.0
|
|
158
|
+
for name in locator.images:
|
|
159
|
+
needle, _ = self.load(name)
|
|
160
|
+
if (
|
|
161
|
+
needle.width > haystack.width
|
|
162
|
+
or needle.height > haystack.height
|
|
163
|
+
):
|
|
164
|
+
continue
|
|
165
|
+
best = max(best, self._score(needle, haystack))
|
|
166
|
+
return best
|
|
167
|
+
|
|
168
|
+
def _score(self, needle: Image.Image, haystack: Image.Image) -> float:
|
|
169
|
+
code = cv2.COLOR_RGB2GRAY if self.grayscale else cv2.COLOR_RGB2BGR
|
|
170
|
+
n = cv2.cvtColor(np.array(needle), code)
|
|
171
|
+
h = cv2.cvtColor(np.array(haystack), code)
|
|
172
|
+
result = cv2.matchTemplate(h, n, cv2.TM_CCOEFF_NORMED)
|
|
173
|
+
_, max_val, _, _ = cv2.minMaxLoc(result)
|
|
174
|
+
return float(max(0.0, min(1.0, max_val)))
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from ..geometry import Match, Region
|
|
4
|
+
from ..locator import Locator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class UIEngine:
|
|
8
|
+
"""UI Automation / Win32 element lookup based on pywinauto."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, backend: str = 'uia'):
|
|
11
|
+
self.backend = backend
|
|
12
|
+
self.window = None # pywinauto wrapper used as search root
|
|
13
|
+
self.window_backend: str | None = None
|
|
14
|
+
self.apps: dict[str, Any] = {}
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def _wrap(element_info, backend: str):
|
|
18
|
+
from pywinauto.backend import registry
|
|
19
|
+
|
|
20
|
+
return registry.backends[backend].generic_wrapper_class(element_info)
|
|
21
|
+
|
|
22
|
+
def find_elements(
|
|
23
|
+
self,
|
|
24
|
+
criteria: dict,
|
|
25
|
+
backend: str | None = None,
|
|
26
|
+
parent=None,
|
|
27
|
+
top_level_only: bool = False,
|
|
28
|
+
use_window: bool = True,
|
|
29
|
+
) -> list:
|
|
30
|
+
from pywinauto import findwindows
|
|
31
|
+
|
|
32
|
+
backend = backend or self.window_backend or self.backend
|
|
33
|
+
root = (
|
|
34
|
+
parent
|
|
35
|
+
if parent is not None
|
|
36
|
+
else (self.window if use_window else None)
|
|
37
|
+
)
|
|
38
|
+
kwargs = dict(criteria)
|
|
39
|
+
found_index = kwargs.pop('found_index', None)
|
|
40
|
+
kwargs.setdefault('visible_only', True)
|
|
41
|
+
if root is not None:
|
|
42
|
+
kwargs['parent'] = root.element_info
|
|
43
|
+
kwargs['top_level_only'] = False
|
|
44
|
+
else:
|
|
45
|
+
kwargs['top_level_only'] = top_level_only
|
|
46
|
+
try:
|
|
47
|
+
infos = findwindows.find_elements(backend=backend, **kwargs)
|
|
48
|
+
except Exception:
|
|
49
|
+
infos = []
|
|
50
|
+
wrappers = [self._wrap(i, backend) for i in infos]
|
|
51
|
+
if found_index is not None:
|
|
52
|
+
idx = int(found_index)
|
|
53
|
+
wrappers = wrappers[idx : idx + 1]
|
|
54
|
+
return wrappers
|
|
55
|
+
|
|
56
|
+
def find_all(self, locator: Locator) -> list[Match]:
|
|
57
|
+
backend = locator.backend or self.window_backend or self.backend
|
|
58
|
+
criteria = dict(locator.criteria)
|
|
59
|
+
if self.window is None:
|
|
60
|
+
# Cheap first pass: top level windows only.
|
|
61
|
+
elements = self.find_elements(
|
|
62
|
+
criteria, backend, top_level_only=True
|
|
63
|
+
)
|
|
64
|
+
if not elements:
|
|
65
|
+
elements = self.find_elements(criteria, backend)
|
|
66
|
+
else:
|
|
67
|
+
elements = self.find_elements(criteria, backend)
|
|
68
|
+
if not elements:
|
|
69
|
+
# the attached window itself (or another top level window)
|
|
70
|
+
elements = self.find_elements(
|
|
71
|
+
criteria,
|
|
72
|
+
backend,
|
|
73
|
+
parent=None,
|
|
74
|
+
top_level_only=True,
|
|
75
|
+
use_window=False,
|
|
76
|
+
)
|
|
77
|
+
return self.to_matches(
|
|
78
|
+
elements, locator.describe(), locator.offset, locator.region
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def to_matches(
|
|
82
|
+
self, elements, source: str, offset=(0, 0), region=None
|
|
83
|
+
) -> list[Match]:
|
|
84
|
+
matches = []
|
|
85
|
+
for el in elements:
|
|
86
|
+
try:
|
|
87
|
+
r = el.rectangle()
|
|
88
|
+
except Exception:
|
|
89
|
+
continue
|
|
90
|
+
if r.width() <= 0 or r.height() <= 0:
|
|
91
|
+
continue
|
|
92
|
+
m = Match(
|
|
93
|
+
r.left,
|
|
94
|
+
r.top,
|
|
95
|
+
r.width(),
|
|
96
|
+
r.height(),
|
|
97
|
+
score=1.0,
|
|
98
|
+
source=source,
|
|
99
|
+
offset=tuple(offset),
|
|
100
|
+
element=el,
|
|
101
|
+
)
|
|
102
|
+
if region is not None and not region.contains_point(*m.center):
|
|
103
|
+
continue
|
|
104
|
+
matches.append(m)
|
|
105
|
+
return matches
|
|
106
|
+
|
|
107
|
+
def find_children(
|
|
108
|
+
self,
|
|
109
|
+
parent,
|
|
110
|
+
criteria: dict | None = None,
|
|
111
|
+
depth: int | None = None,
|
|
112
|
+
backend: str | None = None,
|
|
113
|
+
) -> list:
|
|
114
|
+
"""Descendants of ``parent`` (a pywinauto wrapper) matching ``criteria``.
|
|
115
|
+
``depth=1`` returns direct children only."""
|
|
116
|
+
from pywinauto import findwindows
|
|
117
|
+
|
|
118
|
+
backend = backend or parent.backend.name
|
|
119
|
+
kwargs = dict(criteria or {})
|
|
120
|
+
found_index = kwargs.pop('found_index', None)
|
|
121
|
+
kwargs.setdefault('visible_only', True)
|
|
122
|
+
if depth is not None:
|
|
123
|
+
kwargs['depth'] = int(depth)
|
|
124
|
+
try:
|
|
125
|
+
infos = findwindows.find_elements(
|
|
126
|
+
backend=backend,
|
|
127
|
+
parent=parent.element_info,
|
|
128
|
+
top_level_only=False,
|
|
129
|
+
**kwargs
|
|
130
|
+
)
|
|
131
|
+
except Exception:
|
|
132
|
+
infos = []
|
|
133
|
+
wrappers = [self._wrap(i, backend) for i in infos]
|
|
134
|
+
if found_index is not None:
|
|
135
|
+
wrappers = wrappers[int(found_index) : int(found_index) + 1]
|
|
136
|
+
return wrappers
|
|
137
|
+
|
|
138
|
+
# ---- application / window handling --------------------------------
|
|
139
|
+
def start(
|
|
140
|
+
self,
|
|
141
|
+
alias: str,
|
|
142
|
+
command: str,
|
|
143
|
+
backend: str | None = None,
|
|
144
|
+
timeout: float = 10,
|
|
145
|
+
):
|
|
146
|
+
from pywinauto import Application
|
|
147
|
+
|
|
148
|
+
app = Application(backend=backend or self.backend).start(
|
|
149
|
+
command, timeout=timeout
|
|
150
|
+
)
|
|
151
|
+
self.apps[alias] = app
|
|
152
|
+
return app
|
|
153
|
+
|
|
154
|
+
def connect(
|
|
155
|
+
self,
|
|
156
|
+
alias: str,
|
|
157
|
+
backend: str | None = None,
|
|
158
|
+
timeout: float = 10,
|
|
159
|
+
**kwargs
|
|
160
|
+
):
|
|
161
|
+
from pywinauto import Application
|
|
162
|
+
|
|
163
|
+
app = Application(backend=backend or self.backend).connect(
|
|
164
|
+
timeout=timeout, **kwargs
|
|
165
|
+
)
|
|
166
|
+
self.apps[alias] = app
|
|
167
|
+
return app
|
|
168
|
+
|
|
169
|
+
def attach_window(
|
|
170
|
+
self, criteria: dict, backend: str | None = None, timeout: float = 10
|
|
171
|
+
):
|
|
172
|
+
from pywinauto import Desktop
|
|
173
|
+
|
|
174
|
+
backend = backend or self.backend
|
|
175
|
+
spec = Desktop(backend=backend).window(**criteria)
|
|
176
|
+
wrapper = spec.wait('exists', timeout=timeout)
|
|
177
|
+
self.window = wrapper
|
|
178
|
+
self.window_backend = backend
|
|
179
|
+
try:
|
|
180
|
+
wrapper.set_focus()
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
return wrapper
|
|
184
|
+
|
|
185
|
+
def detach(self):
|
|
186
|
+
self.window = None
|
|
187
|
+
self.window_backend = None
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def region_of(wrapper) -> Region:
|
|
191
|
+
r = wrapper.rectangle()
|
|
192
|
+
return Region(r.left, r.top, r.width(), r.height())
|