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.
@@ -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,4 @@
1
+ from .image import ImageEngine, ImagePathResolver
2
+ from .ui import UIEngine
3
+
4
+ __all__ = ['ImageEngine', 'ImagePathResolver', 'UIEngine']
@@ -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())