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,278 @@
1
+ import sys
2
+
3
+ if (
4
+ sys.platform == 'win32'
5
+ ): # make screen, mouse and UIA coordinates consistent
6
+ import ctypes
7
+
8
+ try:
9
+ ctypes.windll.shcore.SetProcessDpiAwareness(2)
10
+ except Exception:
11
+ try:
12
+ ctypes.windll.user32.SetProcessDPIAware()
13
+ except Exception:
14
+ pass
15
+
16
+ import pyautogui # noqa: E402
17
+ from robotlibcore import DynamicCore # noqa: E402
18
+
19
+ from .context import ElementNotFound, LibraryContext # noqa: E402
20
+ from .geometry import Match, Region # noqa: E402
21
+ from .keywords import ( # noqa: E402
22
+ ApplicationKeywords,
23
+ ConfigKeywords,
24
+ CursorKeywords,
25
+ ElementKeywords,
26
+ FindKeywords,
27
+ KeyboardKeywords,
28
+ MouseKeywords,
29
+ OcrKeywords,
30
+ RegionKeywords,
31
+ WaitKeywords,
32
+ )
33
+ from .locator import Locator, parse_locator # noqa: E402
34
+
35
+ __version__ = '0.1.0'
36
+ __all__ = [
37
+ 'DesktopLibrary',
38
+ 'ElementNotFound',
39
+ 'Locator',
40
+ 'Match',
41
+ 'Region',
42
+ 'parse_locator',
43
+ ]
44
+
45
+ pyautogui.PAUSE = 0.05
46
+
47
+
48
+ class DesktopLibrary(DynamicCore):
49
+ """DesktopLibrary is a Robot Framework library for Windows desktop
50
+ automation. It combines image recognition (``pyautogui`` + OpenCV) with
51
+ UI Automation (``pywinauto``) for WPF, WinForms, UWP and Win32 applications.
52
+
53
+ Keywords are *hybrid*: the engine used is chosen by the format of the
54
+ ``element`` argument.
55
+
56
+ = Element locators =
57
+
58
+ == Image file path ==
59
+
60
+ A string is treated as an image path unless it matches the UI locator
61
+ syntax. Relative paths are resolved against the folders given with the
62
+ ``image_paths`` import argument or `Add Image Path`, in order, then the
63
+ current working directory. The extension can be omitted
64
+ (``.png``, ``.jpg``, ``.jpeg`` and ``.bmp`` are tried).
65
+
66
+ The default similarity is ``0.6`` and can be changed with
67
+ `Set Min Similarity`. A per-call similarity can be given with
68
+ ``image.png=0.85``. Use the ``image:`` prefix to force the image engine.
69
+
70
+ | Click | buttons/save.png |
71
+ | Click | save.png=0.9 |
72
+ | Click | image:C:/refs/save.png |
73
+
74
+ == Dictionary ==
75
+
76
+ | =Key= | =Description= |
77
+ | path | Image path (string) or list of image paths. With a list, the element is *any* of the images (the best match wins). |
78
+ | similarity | Similarity for this locator only (0-1). |
79
+ | offset | Dictionary ``{"x": int, "y": int}`` added to the center of the element when acting. |
80
+ | region | Dictionary ``{"x", "y", "width", "height"}`` limiting where the element is searched. |
81
+ | name, automation_id, class_name, control_type, ... | UI Automation criteria (instead of ``path``). |
82
+ | backend | ``uia`` or ``win32`` for UI criteria. |
83
+ | locator | Any other locator string, so ``offset``/``region`` can be added to it. |
84
+
85
+ | ${ok}= | Create Dictionary | path=ok.png | similarity=0.85 |
86
+ | Click | ${ok} |
87
+ | Click | ${{ {"path": ["ok.png", "ok_dark.png"], "offset": {"x": 10, "y": 0}} }} |
88
+ | Click | ${{ {"path": "ok.png", "region": {"x": 0, "y": 0, "width": 800, "height": 600}} }} |
89
+ | Click | ${{ {"automation_id": "btnSave", "offset": {"x": 5, "y": 5}} }} |
90
+
91
+ == UI Automation locator ==
92
+
93
+ Strings in the form ``key:value`` use pywinauto. Conditions can be combined
94
+ with ``AND``. A ``uia:`` or ``win32:`` prefix selects the backend
95
+ (default is the ``backend`` import argument, ``uia``).
96
+
97
+ | =Key= | =Meaning= |
98
+ | name / title | Element name (window title, button text...). |
99
+ | name_re / title_re | Regular expression for the name. |
100
+ | automation_id / id / auto_id | AutomationId (WPF ``x:Name``, WinForms ``Name``). |
101
+ | class / class_name | Class name. |
102
+ | control_type / type | Control type: ``Button``, ``Edit``, ``ComboBox``... (uia only). |
103
+ | index / found_index | 0 based index when multiple elements match. |
104
+ | handle, process | Window handle or process id. |
105
+
106
+ | Attach To Window | name:Sample App |
107
+ | Click | automation_id:btnSave |
108
+ | Click | name:OK AND control_type:Button |
109
+ | Click | win32:class:Button AND name:Cancel |
110
+
111
+ Searches are made inside the window set with `Attach To Window`, or in the
112
+ whole desktop otherwise (slower).
113
+
114
+ == Region and match ==
115
+
116
+ Regions (``[x, y, width, height]`` or values returned by keywords such
117
+ as `Get Extended Region From Image`) and matches can be used as elements:
118
+ the action happens at their center.
119
+
120
+ == Looping over elements ==
121
+
122
+ `Find Element`, `Find Elements` and `Find Child Elements` return matches
123
+ that can be passed to any keyword. UI matches keep the UI Automation
124
+ element; image matches are screen positions (they become stale if the
125
+ screen changes).
126
+
127
+ | @{items}= | Find Elements | control_type:ListItem |
128
+ | FOR | ${item} | IN | @{items} |
129
+ | | ${text}= | Get Element Text | ${item} |
130
+ | | IF | $text == "Item 3" | Click | ${item} |
131
+ | END |
132
+
133
+ = Importing =
134
+
135
+ | Library | DesktopLibrary | image_paths=${CURDIR}/images;${CURDIR}/shared | similarity=0.7 | timeout=10 |
136
+
137
+ = Busy cursor handling =
138
+
139
+ Desktop applications often load data in background, so a fixed wait is
140
+ not reliable. DesktopLibrary watches the mouse cursor (Windows
141
+ ``GetCursorInfo``) and, when it is *busy* (``wait`` or ``appstarting`` by
142
+ default), waits until it is not busy for a stability window
143
+ (``0.5`` seconds by default):
144
+
145
+ - before searching elements in action keywords (`Click`, `Input Text`, `Drag And Drop`,
146
+ `Get Element Text`, `Wait Until Screen Contain`, ...),
147
+ - before mouse and keyboard actions, also without element (`Press Special Key`,
148
+ `Type With Modifiers`, `Input Text` with ``${EMPTY}``, `Mouse Down`, wheel...),
149
+ - after action keywords: the cursor is watched for ``busy_cursor_after_delay``
150
+ (``0.2s`` by default) and, if the action made it busy, the keyword only
151
+ returns when the application is ready again.
152
+
153
+ Action keywords accept the named argument ``ignore_busy_error=True`` to log
154
+ a warning instead of failing when the busy cursor wait times out:
155
+
156
+ | Click | automation_id:btnLoad | ignore_busy_error=True |
157
+
158
+ Checks such as `Exists`, `Screen Should Not Contain` and `Get Match Score`
159
+ never wait. `Wait Until Screen Contain`/`Screen Should Contain` limit the
160
+ cursor wait to their own timeout. The cursor is read at its current
161
+ position; the mouse is not moved for the check.
162
+
163
+ If the cursor stays busy longer than ``busy_cursor_timeout`` (default
164
+ ``30s``) the keyword fails by default, or logs a warning and continues when
165
+ ``busy_cursor_fail=False``. Application specific cursors are reported as
166
+ ``custom`` and are *not* busy unless added with `Set Busy Cursor Types`.
167
+
168
+ | Library | DesktopLibrary | busy_cursor_timeout=1 min | busy_cursor_fail=False |
169
+ | Set Busy Cursor Wait | False | | | # disable automatic waiting |
170
+ | Set Busy Cursor Types | wait,appstarting,custom |
171
+ | Wait Until Cursor Is Not Busy | 2 min |
172
+ | ${cursor}= | Get Current Cursor Type |
173
+
174
+ = Example =
175
+
176
+ | *** Settings ***
177
+ | Library DesktopLibrary image_paths=${CURDIR}/images
178
+ |
179
+ | *** Test Cases ***
180
+ | Save Document
181
+ | Open Application notepad.exe
182
+ | Attach To Window name_re:.*Notepad
183
+ | Input Text class:Edit Hello from Robot
184
+ | Type With Modifiers s CTRL
185
+ | Wait Until Screen Contain save_dialog.png 10
186
+ | Click ${{ {"path": ["save_btn.png", "save_btn_dark.png"], "similarity": 0.8} }}
187
+ | [Teardown] Close Application notepad.exe
188
+ """
189
+
190
+ ROBOT_LIBRARY_SCOPE = 'GLOBAL'
191
+ ROBOT_LIBRARY_VERSION = __version__
192
+ ROBOT_LIBRARY_DOC_FORMAT = 'ROBOT'
193
+
194
+ def __init__(
195
+ self,
196
+ image_paths=None,
197
+ similarity: float = 0.6,
198
+ timeout='5s',
199
+ backend: str = 'uia',
200
+ capture_folder: str | None = None,
201
+ scan_rate: float = 10.0,
202
+ screen_id: int = 0,
203
+ show_actions: bool = False,
204
+ busy_cursor_wait: bool = True,
205
+ busy_cursor_timeout='30s',
206
+ busy_cursor_stable='0.5s',
207
+ busy_cursor_types='wait,appstarting',
208
+ busy_cursor_fail: bool = True,
209
+ busy_cursor_after_delay='0.2s',
210
+ show_warnings: bool = True,
211
+ ):
212
+ """
213
+ | =Argument= | =Description= |
214
+ | image_paths | Folders for relative image paths: list or string separated by ``;``. |
215
+ | similarity | Default image similarity (0-1). Default ``0.6``. |
216
+ | timeout | Default wait timeout. Default ``5s``. |
217
+ | backend | Default UI backend: ``uia`` or ``win32``. |
218
+ | capture_folder | Folder for captures. Default ``${OUTPUT DIR}``. |
219
+ | scan_rate | Searches per second while waiting. Default ``10``. |
220
+ | screen_id | Monitor used as default search area: ``0`` primary, ``-1`` all monitors. Default ``0``. |
221
+ | show_actions | Highlight targets before acting. Default ``False``. |
222
+ | busy_cursor_wait | Automatically wait while the cursor is busy. Default ``True``. |
223
+ | busy_cursor_timeout | Maximum busy cursor wait. Default ``30s``. |
224
+ | busy_cursor_stable | Time the cursor must stay not busy. Default ``0.5s``. |
225
+ | busy_cursor_types | Cursor types considered busy. Default ``wait,appstarting``. |
226
+ | busy_cursor_fail | Fail (``True``) or warn and continue (``False``) on busy timeout. Default ``True``. |
227
+ | busy_cursor_after_delay | Time watched after an action for the cursor to become busy. Default ``0.2s``. |
228
+ | show_warnings | Emit library warnings (e.g. ignored busy cursor timeouts). When ``False`` they are logged as INFO only. Default ``True``. |
229
+
230
+ Examples:
231
+ | Library | DesktopLibrary |
232
+ | Library | DesktopLibrary | image_paths=${CURDIR}/images | similarity=0.8 |
233
+ | Library | DesktopLibrary | backend=win32 | timeout=10s |
234
+ | Library | DesktopLibrary | busy_cursor_timeout=2 min | busy_cursor_fail=False |
235
+ | Library | DesktopLibrary | busy_cursor_fail=False | show_warnings=False |
236
+ """
237
+
238
+ def as_bool(value):
239
+ return (
240
+ value
241
+ if isinstance(value, bool)
242
+ else str(value).lower() in ('true', '1', 'yes', 'on')
243
+ )
244
+
245
+ show_actions = as_bool(show_actions)
246
+ self.ctx = LibraryContext(
247
+ image_paths=image_paths,
248
+ similarity=similarity,
249
+ timeout=timeout,
250
+ backend=backend,
251
+ capture_folder=capture_folder,
252
+ scan_rate=scan_rate,
253
+ screen_id=screen_id,
254
+ show_actions=show_actions,
255
+ busy_cursor_wait=as_bool(busy_cursor_wait),
256
+ busy_cursor_timeout=busy_cursor_timeout,
257
+ busy_cursor_stable=busy_cursor_stable,
258
+ busy_cursor_types=busy_cursor_types,
259
+ busy_cursor_fail=as_bool(busy_cursor_fail),
260
+ busy_cursor_after_delay=busy_cursor_after_delay,
261
+ show_warnings=as_bool(show_warnings),
262
+ )
263
+ components = [
264
+ cls(self.ctx)
265
+ for cls in (
266
+ MouseKeywords,
267
+ KeyboardKeywords,
268
+ WaitKeywords,
269
+ RegionKeywords,
270
+ OcrKeywords,
271
+ ApplicationKeywords,
272
+ ElementKeywords,
273
+ ConfigKeywords,
274
+ CursorKeywords,
275
+ FindKeywords,
276
+ )
277
+ ]
278
+ super().__init__(components)
@@ -0,0 +1,272 @@
1
+ import time
2
+ from pathlib import Path
3
+ from typing import Any, Callable
4
+
5
+ import mss
6
+ from robot.api import logger
7
+ from robot.utils import timestr_to_secs
8
+
9
+ from .cursor import CursorMonitor, normalize_cursor_types
10
+ from .engines import ImageEngine, ImagePathResolver, UIEngine
11
+ from .geometry import Match, Region
12
+ from .locator import Locator, parse_locator
13
+ from .overlay import Overlay
14
+
15
+
16
+ class ElementNotFound(AssertionError):
17
+ pass
18
+
19
+
20
+ class LibraryContext:
21
+ """Shared state and core lookup logic used by every keyword group."""
22
+
23
+ def __init__(
24
+ self,
25
+ image_paths=None,
26
+ similarity=0.6,
27
+ timeout=5.0,
28
+ backend='uia',
29
+ capture_folder=None,
30
+ scan_rate=10.0,
31
+ screen_id=0,
32
+ show_actions=False,
33
+ busy_cursor_wait=True,
34
+ busy_cursor_timeout=30.0,
35
+ busy_cursor_stable=0.5,
36
+ busy_cursor_types='wait,appstarting',
37
+ busy_cursor_fail=True,
38
+ busy_cursor_after_delay=0.2,
39
+ show_warnings=True,
40
+ ):
41
+ self.show_warnings = show_warnings
42
+ self.cursor = CursorMonitor(
43
+ enabled=busy_cursor_wait,
44
+ timeout=self.to_seconds(busy_cursor_timeout),
45
+ stable_duration=self.to_seconds(busy_cursor_stable),
46
+ busy_types=normalize_cursor_types(busy_cursor_types),
47
+ fail_on_timeout=busy_cursor_fail,
48
+ after_delay=self.to_seconds(busy_cursor_after_delay),
49
+ logger=self.log,
50
+ )
51
+ self.resolver = ImagePathResolver()
52
+ for p in self._split_paths(image_paths):
53
+ self.resolver.add(p)
54
+ self.image = ImageEngine(self.resolver)
55
+ self.ui = UIEngine(backend=str(backend).lower())
56
+ self.overlay = Overlay()
57
+ self.similarity = float(similarity)
58
+ self.timeout = self.to_seconds(timeout)
59
+ self.scan_rate = float(scan_rate)
60
+ self.screen_id = int(screen_id)
61
+ self.roi: Region | None = None
62
+ self.last_match: Match | None = None
63
+ self._capture_folder = capture_folder
64
+ self.capture_matched_image = False
65
+ self.show_actions = bool(show_actions)
66
+ self.slow_motion_delay = 0.5
67
+ self.move_mouse_delay = 0.0
68
+ self.ocr_enabled = True
69
+ self._capture_counter = 0
70
+
71
+ # ---- helpers ------------------------------------------------------------
72
+ def log(self, message: str, level: str = 'INFO'):
73
+ """Logs ``message``; warnings are downgraded to INFO when
74
+ ``show_warnings`` is disabled (kept in the log, hidden from console/report)."""
75
+ if level.upper() == 'WARN' and not self.show_warnings:
76
+ level = 'INFO'
77
+ message = f'[warning suppressed] {message}'
78
+ logger.write(message, level)
79
+
80
+ @staticmethod
81
+ def _split_paths(value) -> list[str]:
82
+ if not value:
83
+ return []
84
+ if isinstance(value, str):
85
+ return [p.strip() for p in value.split(';') if p.strip()]
86
+ return [str(p) for p in value]
87
+
88
+ @staticmethod
89
+ def to_seconds(value, default: float | None = None) -> float:
90
+ if value is None or (isinstance(value, str) and value.strip() == ''):
91
+ if default is None:
92
+ raise ValueError('Timeout value is required.')
93
+ return default
94
+ return float(timestr_to_secs(value))
95
+
96
+ @property
97
+ def capture_folder(self) -> Path:
98
+ folder = self._capture_folder
99
+ if not folder:
100
+ try:
101
+ from robot.libraries.BuiltIn import BuiltIn
102
+
103
+ folder = BuiltIn().get_variable_value('${OUTPUT DIR}')
104
+ except Exception:
105
+ folder = None
106
+ path = Path(folder or Path.cwd())
107
+ path.mkdir(parents=True, exist_ok=True)
108
+ return path
109
+
110
+ @capture_folder.setter
111
+ def capture_folder(self, value):
112
+ self._capture_folder = value
113
+
114
+ def next_capture_path(
115
+ self, name: str = '', prefix: str = 'capture'
116
+ ) -> Path:
117
+ if name:
118
+ p = Path(name)
119
+ if p.suffix.lower() not in ('.png', '.jpg', '.jpeg', '.bmp'):
120
+ p = p.with_name(p.name + '.png')
121
+ return p if p.is_absolute() else self.capture_folder / p
122
+ self._capture_counter += 1
123
+ stamp = time.strftime('%Y%m%d-%H%M%S')
124
+ return (
125
+ self.capture_folder
126
+ / f'{prefix}-{stamp}-{self._capture_counter}.png'
127
+ )
128
+
129
+ # ---- screens ------------------------------------------------------------
130
+ @staticmethod
131
+ def monitors() -> list[Region]:
132
+ """Monitors with the primary one (containing 0,0) first."""
133
+ with mss.mss() as sct:
134
+ mons = sct.monitors[1:] or sct.monitors[:1]
135
+ regions = [
136
+ Region(m['left'], m['top'], m['width'], m['height'])
137
+ for m in mons
138
+ ]
139
+ regions.sort(key=lambda r: (not r.contains_point(0, 0), r.x, r.y))
140
+ return regions
141
+
142
+ @staticmethod
143
+ def virtual_screen() -> Region:
144
+ with mss.mss() as sct:
145
+ m = sct.monitors[0]
146
+ return Region(m['left'], m['top'], m['width'], m['height'])
147
+
148
+ def screen(self) -> Region:
149
+ if self.screen_id == -1:
150
+ return self.virtual_screen()
151
+ mons = self.monitors()
152
+ if not 0 <= self.screen_id < len(mons):
153
+ raise ValueError(
154
+ f'Screen id {self.screen_id} out of range (0..{len(mons) - 1}).'
155
+ )
156
+ return mons[self.screen_id]
157
+
158
+ def search_region(self, locator: Locator | None = None) -> Region:
159
+ if locator is not None and locator.region is not None:
160
+ return locator.region
161
+ return self.roi or self.screen()
162
+
163
+ # ---- lookup ---------------------------------------------------------------
164
+ def find_now(self, locator: Locator) -> list[Match]:
165
+ if locator.kind == 'region':
166
+ fixed = locator.fixed
167
+ return [
168
+ Match(
169
+ fixed.x,
170
+ fixed.y,
171
+ fixed.width,
172
+ fixed.height,
173
+ score=getattr(fixed, 'score', 1.0),
174
+ source='region',
175
+ offset=tuple(locator.offset),
176
+ element=getattr(fixed, 'element', None),
177
+ )
178
+ ]
179
+ if locator.kind == 'image':
180
+ similarity = locator.similarity or self.similarity
181
+ return self.image.find_all(
182
+ locator, self.search_region(locator), similarity
183
+ )
184
+ return self.ui.find_all(locator)
185
+
186
+ def _poll(self, condition: Callable[[], Any], timeout: float):
187
+ deadline = time.monotonic() + timeout
188
+ interval = 1.0 / self.scan_rate if self.scan_rate > 0 else 0.1
189
+ while True:
190
+ result = condition()
191
+ if result:
192
+ return result
193
+ if time.monotonic() >= deadline:
194
+ return result
195
+ time.sleep(interval)
196
+
197
+ def find(
198
+ self,
199
+ target: Any,
200
+ timeout: Any = None,
201
+ region: Region | None = None,
202
+ x_offset: int = 0,
203
+ y_offset: int = 0,
204
+ wait_cursor: bool = True,
205
+ ) -> Match:
206
+ locator = parse_locator(target)
207
+ if region is not None:
208
+ locator = locator.with_region(region)
209
+ if x_offset or y_offset:
210
+ locator = locator.with_extra_offset(x_offset, y_offset)
211
+ secs = self.to_seconds(timeout, self.timeout)
212
+ if wait_cursor:
213
+ self.cursor.auto_wait()
214
+ matches = self._poll(lambda: self.find_now(locator), secs)
215
+ if not matches:
216
+ raise ElementNotFound(
217
+ f"Element '{locator.describe()}' not found in {secs:g} seconds."
218
+ )
219
+ match = max(matches, key=lambda m: m.score)
220
+ self.last_match = match
221
+ self._after_match(match)
222
+ return match
223
+
224
+ def find_all(
225
+ self, target: Any, timeout: Any = 0, wait_cursor: bool = False
226
+ ) -> list[Match]:
227
+ locator = parse_locator(target)
228
+ secs = self.to_seconds(timeout, 0)
229
+ if wait_cursor:
230
+ self.cursor.auto_wait()
231
+ return self._poll(lambda: self.find_now(locator), secs) or []
232
+
233
+ def exists(self, target: Any, timeout: Any = None) -> bool:
234
+ try:
235
+ self.find(target, timeout, wait_cursor=False)
236
+ return True
237
+ except ElementNotFound:
238
+ return False
239
+
240
+ def wait_vanish(self, target: Any, timeout: Any = None) -> bool:
241
+ locator = parse_locator(target)
242
+ secs = self.to_seconds(timeout, self.timeout)
243
+ self.cursor.auto_wait(secs)
244
+ return bool(self._poll(lambda: not self.find_now(locator), secs))
245
+
246
+ def resolve_or_last(self, target: Any, timeout: Any = None) -> Match:
247
+ if target is None or (
248
+ isinstance(target, str) and target.strip() == ''
249
+ ):
250
+ if self.last_match is None:
251
+ raise ElementNotFound('No previous match available.')
252
+ return self.last_match
253
+ return self.find(target, timeout)
254
+
255
+ # ---- visual feedback ----------------------------------------------------
256
+ def _after_match(self, match: Match):
257
+ if self.capture_matched_image:
258
+ try:
259
+ path = self.next_capture_path(prefix='match')
260
+ self.image.grab(match).save(path)
261
+ logger.info(
262
+ f'<img src="{path.as_posix()}" width="{min(match.width, 400)}">',
263
+ html=True,
264
+ )
265
+ except Exception as err: # pragma: no cover - diagnostics only
266
+ logger.debug(f'Could not capture matched image: {err}')
267
+ logger.info(f'Found {match}')
268
+
269
+ def show_action(self, region: Region):
270
+ if self.show_actions:
271
+ self.overlay.highlight(region, self.slow_motion_delay)
272
+ time.sleep(self.slow_motion_delay)