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
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
from robot.api.deco import keyword
|
|
4
|
+
|
|
5
|
+
from ..geometry import Match, Region, to_region
|
|
6
|
+
from ..locator import parse_locator
|
|
7
|
+
from .base import KeywordGroup
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RegionKeywords(KeywordGroup):
|
|
11
|
+
# ---- ROI -----------------------------------------------------------------
|
|
12
|
+
@keyword
|
|
13
|
+
def set_roi(self, coordinates, timeout: float = 0):
|
|
14
|
+
"""Sets the region of interest. Image searches without an explicit
|
|
15
|
+
``region`` are limited to it. Optionally highlights it for ``timeout`` seconds.
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
| ${coordinates}= | Create List | 0 | 0 | 800 | 600 |
|
|
19
|
+
| Set ROI | ${coordinates} |
|
|
20
|
+
| Set ROI | [100, 100, 400, 300] | 2 |
|
|
21
|
+
"""
|
|
22
|
+
self.ctx.roi = to_region(coordinates)
|
|
23
|
+
if float(timeout) > 0:
|
|
24
|
+
self.ctx.overlay.highlight(self.ctx.roi, float(timeout))
|
|
25
|
+
|
|
26
|
+
@keyword
|
|
27
|
+
def reset_roi(self):
|
|
28
|
+
"""Resets the region of interest to the whole current screen.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
| Reset ROI |
|
|
32
|
+
"""
|
|
33
|
+
self.ctx.roi = None
|
|
34
|
+
|
|
35
|
+
@keyword
|
|
36
|
+
def highlight_roi(self, timeout: float):
|
|
37
|
+
"""Highlights the current region of interest for ``timeout`` seconds.
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
| Highlight ROI | 2 |
|
|
41
|
+
"""
|
|
42
|
+
self.ctx.overlay.highlight(self.ctx.search_region(), float(timeout))
|
|
43
|
+
|
|
44
|
+
# ---- extended regions ------------------------------------------------------
|
|
45
|
+
@keyword
|
|
46
|
+
def get_extended_region_from(
|
|
47
|
+
self, element, direction: str, number_of_times_to_repeat
|
|
48
|
+
):
|
|
49
|
+
"""Returns a region above or below ``element`` with the same width and
|
|
50
|
+
height multiplied by ``number_of_times_to_repeat``.
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
| ${region}= | Get Extended Region From | label.png | below | 2 |
|
|
54
|
+
"""
|
|
55
|
+
if str(direction).lower() not in ('above', 'below'):
|
|
56
|
+
raise ValueError("Direction must be 'above' or 'below'.")
|
|
57
|
+
return self.ctx.find(element).extended(
|
|
58
|
+
direction, number_of_times_to_repeat
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@keyword
|
|
62
|
+
def get_extended_region_from_image(
|
|
63
|
+
self, element, direction: str, number_of_times_to_repeat=1
|
|
64
|
+
):
|
|
65
|
+
"""Returns a region next to ``element`` (``above``, ``below``, ``left``,
|
|
66
|
+
``right`` or ``original``) scaled by ``number_of_times_to_repeat``.
|
|
67
|
+
|
|
68
|
+
With ``original`` the region is the element itself.
|
|
69
|
+
|
|
70
|
+
Examples:
|
|
71
|
+
| ${region}= | Get Extended Region From Image | image.png | below | 1 |
|
|
72
|
+
| ${region}= | Get Extended Region From Image | name:Total | right | 3 |
|
|
73
|
+
| ${region}= | Get Extended Region From Image | image.png | original | 1 |
|
|
74
|
+
"""
|
|
75
|
+
return self.ctx.find(element).extended(
|
|
76
|
+
direction, number_of_times_to_repeat
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@keyword
|
|
80
|
+
def get_extended_region_from_region(
|
|
81
|
+
self, region, direction: str, number_of_times_to_repeat=1
|
|
82
|
+
):
|
|
83
|
+
"""Same as `Get Extended Region From Image` but starting from ``region``.
|
|
84
|
+
|
|
85
|
+
Example:
|
|
86
|
+
| ${below}= | Get Extended Region From Region | ${another_region} | below | 1 |
|
|
87
|
+
"""
|
|
88
|
+
return to_region(region).extended(direction, number_of_times_to_repeat)
|
|
89
|
+
|
|
90
|
+
@keyword
|
|
91
|
+
def from_region_jump_to(
|
|
92
|
+
self, region, direction: str, jumps: int, margin: int = 1
|
|
93
|
+
):
|
|
94
|
+
"""Returns a copy of ``region`` translated ``jumps`` times its own size
|
|
95
|
+
plus ``margin`` pixels in ``direction`` (``above``, ``below``, ``left``, ``right``).
|
|
96
|
+
|
|
97
|
+
Example:
|
|
98
|
+
| ${next_row}= | From Region Jump To | ${row} | below | 4 | 1 |
|
|
99
|
+
"""
|
|
100
|
+
return to_region(region).jump(direction, jumps, margin)
|
|
101
|
+
|
|
102
|
+
@keyword
|
|
103
|
+
def return_match_from_region(self, region, target):
|
|
104
|
+
"""Searches ``target`` inside ``region`` and returns the match, usable
|
|
105
|
+
with `Click On Match`.
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
| ${region}= | Get Extended Region From | header.png | below | 3 |
|
|
109
|
+
| ${match}= | Return Match From Region | ${region} | item.png |
|
|
110
|
+
| Click On Match | ${match} |
|
|
111
|
+
"""
|
|
112
|
+
locator = parse_locator(target).with_region(to_region(region))
|
|
113
|
+
return self.ctx.find(locator)
|
|
114
|
+
|
|
115
|
+
@keyword
|
|
116
|
+
def get_region(self, element) -> Region:
|
|
117
|
+
"""Returns the region (``x``, ``y``, ``width``, ``height``) of ``element``.
|
|
118
|
+
|
|
119
|
+
Example:
|
|
120
|
+
| ${r}= | Get Region | name:OK |
|
|
121
|
+
| Log | ${r.x} ${r.y} ${r.width} ${r.height} |
|
|
122
|
+
"""
|
|
123
|
+
return Region(*self.ctx.find(element).to_tuple())
|
|
124
|
+
|
|
125
|
+
# ---- screens ---------------------------------------------------------------
|
|
126
|
+
@keyword
|
|
127
|
+
def change_screen_id(self, screen_id: int):
|
|
128
|
+
"""Selects the monitor used as default search area. ``0`` is the
|
|
129
|
+
primary monitor; ``-1`` means all monitors (whole virtual desktop).
|
|
130
|
+
|
|
131
|
+
Examples:
|
|
132
|
+
| Change Screen Id | 1 |
|
|
133
|
+
| Change Screen Id | -1 |
|
|
134
|
+
"""
|
|
135
|
+
screen_id = int(screen_id)
|
|
136
|
+
count = len(self.ctx.monitors())
|
|
137
|
+
if not -1 <= screen_id < count:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f'Screen id {screen_id} out of range (0..{count - 1}).'
|
|
140
|
+
)
|
|
141
|
+
self.ctx.screen_id = screen_id
|
|
142
|
+
|
|
143
|
+
@keyword
|
|
144
|
+
def get_current_screen_id(self) -> int:
|
|
145
|
+
"""Returns the current screen id.
|
|
146
|
+
|
|
147
|
+
Example:
|
|
148
|
+
| ${id}= | Get Current Screen Id |
|
|
149
|
+
"""
|
|
150
|
+
return self.ctx.screen_id
|
|
151
|
+
|
|
152
|
+
@keyword
|
|
153
|
+
def get_number_of_screens(self) -> int:
|
|
154
|
+
"""Returns the number of connected monitors.
|
|
155
|
+
|
|
156
|
+
Example:
|
|
157
|
+
| ${n}= | Get Number Of Screens |
|
|
158
|
+
"""
|
|
159
|
+
return len(self.ctx.monitors())
|
|
160
|
+
|
|
161
|
+
@keyword
|
|
162
|
+
def get_screen_coordinates(self) -> list:
|
|
163
|
+
"""Returns ``[x, y, width, height]`` of the current screen.
|
|
164
|
+
|
|
165
|
+
Example:
|
|
166
|
+
| @{coordinates}= | Get Screen Coordinates |
|
|
167
|
+
"""
|
|
168
|
+
return self.ctx.screen().to_list()
|
|
169
|
+
|
|
170
|
+
# ---- capture ---------------------------------------------------------------
|
|
171
|
+
def _capture(
|
|
172
|
+
self, region: Region, name: str = '', prefix='capture'
|
|
173
|
+
) -> str:
|
|
174
|
+
path = self.ctx.next_capture_path(name, prefix)
|
|
175
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
self.ctx.image.grab(region).save(path)
|
|
177
|
+
from robot.api import logger
|
|
178
|
+
|
|
179
|
+
logger.info(
|
|
180
|
+
f'<img src="{path.as_posix()}" width="{min(region.width, 800)}">',
|
|
181
|
+
html=True,
|
|
182
|
+
)
|
|
183
|
+
return str(path)
|
|
184
|
+
|
|
185
|
+
@keyword
|
|
186
|
+
def capture_screen(self, image_name: str = '') -> str:
|
|
187
|
+
"""Captures the current screen and returns the file path.
|
|
188
|
+
|
|
189
|
+
Examples:
|
|
190
|
+
| ${file}= | Capture Screen |
|
|
191
|
+
| ${file}= | Capture Screen | home.png |
|
|
192
|
+
"""
|
|
193
|
+
return self._capture(self.ctx.screen(), image_name, 'screen')
|
|
194
|
+
|
|
195
|
+
@keyword
|
|
196
|
+
def capture_region(self, coordinates, image_name: str = '') -> str:
|
|
197
|
+
"""Captures region ``coordinates`` ``[x, y, w, h]`` and returns the file path.
|
|
198
|
+
|
|
199
|
+
Relative names are saved in the capture folder (see `Set Capture Folder`).
|
|
200
|
+
|
|
201
|
+
Examples:
|
|
202
|
+
| ${coor}= | Create List | 0 | 0 | 300 | 200 |
|
|
203
|
+
| ${file}= | Capture Region | ${coor} | demo.png |
|
|
204
|
+
| ${file}= | Capture Region | ${coor} |
|
|
205
|
+
"""
|
|
206
|
+
return self._capture(to_region(coordinates), image_name, 'region')
|
|
207
|
+
|
|
208
|
+
@keyword
|
|
209
|
+
def capture_roi(self, image_name: str = '') -> str:
|
|
210
|
+
"""Captures the region of interest and returns the file path.
|
|
211
|
+
|
|
212
|
+
Example:
|
|
213
|
+
| ${file}= | Capture ROI | roi.png |
|
|
214
|
+
"""
|
|
215
|
+
return self._capture(self.ctx.search_region(), image_name, 'roi')
|
|
216
|
+
|
|
217
|
+
@keyword
|
|
218
|
+
def capture_element(self, element, image_name: str = '') -> str:
|
|
219
|
+
"""Captures the area of ``element`` and returns the file path. Useful
|
|
220
|
+
to create reference images from UI Automation elements.
|
|
221
|
+
|
|
222
|
+
Example:
|
|
223
|
+
| ${file}= | Capture Element | automation_id:btnSave | images/save.png |
|
|
224
|
+
"""
|
|
225
|
+
match = self.ctx.find(element)
|
|
226
|
+
return self._capture(Region(*match.to_tuple()), image_name, 'element')
|
|
227
|
+
|
|
228
|
+
@keyword
|
|
229
|
+
def select_region(self, message: str = 'Select a region') -> list:
|
|
230
|
+
"""Lets the user drag a rectangle on screen, captures it and returns
|
|
231
|
+
``[captured_image_path, x, y, w, h]``. Fails if cancelled with ESC.
|
|
232
|
+
|
|
233
|
+
Example:
|
|
234
|
+
| @{selected}= | Select Region | Select the area to capture |
|
|
235
|
+
"""
|
|
236
|
+
region = self.ctx.overlay.select_region(message, self.ctx.screen())
|
|
237
|
+
if region is None:
|
|
238
|
+
raise AssertionError('Region selection was cancelled.')
|
|
239
|
+
time.sleep(0.2)
|
|
240
|
+
return [self._capture(region, '', 'selected'), *region.to_list()]
|
|
241
|
+
|
|
242
|
+
# ---- highlight -------------------------------------------------------------
|
|
243
|
+
@keyword
|
|
244
|
+
def highlight(self, element, secs=''):
|
|
245
|
+
"""Highlights ``element``. If ``secs`` is given the highlight vanishes
|
|
246
|
+
automatically, otherwise it stays until `Clear Highlight`.
|
|
247
|
+
|
|
248
|
+
Examples:
|
|
249
|
+
| Highlight | button.png | 2 |
|
|
250
|
+
| Highlight | name:OK |
|
|
251
|
+
| Clear Highlight | name:OK |
|
|
252
|
+
"""
|
|
253
|
+
match = self.ctx.find(element)
|
|
254
|
+
seconds = float(secs) if str(secs).strip() else 0
|
|
255
|
+
self.ctx.overlay.highlight(match, seconds, key=_key(element))
|
|
256
|
+
|
|
257
|
+
@keyword
|
|
258
|
+
def highlight_region(self, coordinates, timeout: float):
|
|
259
|
+
"""Highlights region ``coordinates`` for ``timeout`` seconds.
|
|
260
|
+
|
|
261
|
+
Example:
|
|
262
|
+
| Highlight Region | [0, 0, 200, 100] | 2 |
|
|
263
|
+
"""
|
|
264
|
+
self.ctx.overlay.highlight(to_region(coordinates), float(timeout))
|
|
265
|
+
|
|
266
|
+
@keyword
|
|
267
|
+
def clear_highlight(self, element):
|
|
268
|
+
"""Removes the highlight created by `Highlight` for ``element``.
|
|
269
|
+
|
|
270
|
+
Example:
|
|
271
|
+
| Clear Highlight | button.png |
|
|
272
|
+
"""
|
|
273
|
+
self.ctx.overlay.clear(_key(element))
|
|
274
|
+
|
|
275
|
+
@keyword
|
|
276
|
+
def clear_all_highlights(self):
|
|
277
|
+
"""Removes all highlights from the screen.
|
|
278
|
+
|
|
279
|
+
Example:
|
|
280
|
+
| Clear All Highlights |
|
|
281
|
+
"""
|
|
282
|
+
self.ctx.overlay.clear_all()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _key(element) -> str:
|
|
286
|
+
if isinstance(element, (Match, Region)):
|
|
287
|
+
return str(Region(*element.to_tuple()))
|
|
288
|
+
return repr(element) if not isinstance(element, str) else element
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
from robot.api.deco import keyword
|
|
4
|
+
|
|
5
|
+
from ..locator import parse_locator
|
|
6
|
+
from .base import KeywordGroup
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WaitKeywords(KeywordGroup):
|
|
10
|
+
@keyword
|
|
11
|
+
def exists(self, element, timeout='') -> bool:
|
|
12
|
+
"""Returns ``True`` if ``element`` appears within ``timeout``, ``False`` otherwise.
|
|
13
|
+
|
|
14
|
+
``timeout`` defaults to the library timeout (see `Set Timeout`).
|
|
15
|
+
|
|
16
|
+
Examples:
|
|
17
|
+
| ${found}= | Exists | popup.png | 0 |
|
|
18
|
+
| ${found}= | Exists | name:Confirm | 3s |
|
|
19
|
+
| IF | ${found} | Click | ok.png |
|
|
20
|
+
"""
|
|
21
|
+
return self.ctx.exists(element, timeout)
|
|
22
|
+
|
|
23
|
+
@keyword
|
|
24
|
+
def screen_should_contain(self, element, timeout=''):
|
|
25
|
+
"""Fails unless ``element`` is visible (waiting up to ``timeout``).
|
|
26
|
+
|
|
27
|
+
Examples:
|
|
28
|
+
| Screen Should Contain | logo.png |
|
|
29
|
+
| Screen Should Contain | automation_id:mainWindow | 10s |
|
|
30
|
+
"""
|
|
31
|
+
secs = self.ctx.to_seconds(timeout, self.ctx.timeout)
|
|
32
|
+
self.ctx.cursor.auto_wait(secs)
|
|
33
|
+
self.ctx.find(element, secs, wait_cursor=False)
|
|
34
|
+
|
|
35
|
+
@keyword
|
|
36
|
+
def screen_should_not_contain(self, element):
|
|
37
|
+
"""Fails if ``element`` is currently visible (no waiting).
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
| Screen Should Not Contain | error.png |
|
|
41
|
+
"""
|
|
42
|
+
locator = parse_locator(element)
|
|
43
|
+
if self.ctx.find_now(locator):
|
|
44
|
+
raise AssertionError(
|
|
45
|
+
f"Screen should not contain '{locator.describe()}'."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@keyword
|
|
49
|
+
def wait_until_screen_contain(self, element, timeout=''):
|
|
50
|
+
"""Waits until ``element`` appears on screen and returns the match.
|
|
51
|
+
|
|
52
|
+
Examples:
|
|
53
|
+
| Wait Until Screen Contain | loaded.png | 30 |
|
|
54
|
+
| ${m}= | Wait Until Screen Contain | name:Ready | 10s |
|
|
55
|
+
"""
|
|
56
|
+
secs = self.ctx.to_seconds(timeout, self.ctx.timeout)
|
|
57
|
+
self.ctx.cursor.auto_wait(secs)
|
|
58
|
+
return self.ctx.find(element, secs, wait_cursor=False)
|
|
59
|
+
|
|
60
|
+
@keyword
|
|
61
|
+
def wait_until_screen_not_contain(self, element, timeout=''):
|
|
62
|
+
"""Waits until ``element`` disappears from the screen.
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
| Wait Until Screen Not Contain | spinner.png | 60 |
|
|
66
|
+
"""
|
|
67
|
+
if not self.ctx.wait_vanish(element, timeout):
|
|
68
|
+
raise AssertionError(
|
|
69
|
+
f"Element '{parse_locator(element).describe()}' still visible after timeout."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@keyword
|
|
73
|
+
def wait_for_image(self, wanted_element, not_wanted_element, timeout):
|
|
74
|
+
"""Waits for ``wanted_element``; fails if ``not_wanted_element`` appears
|
|
75
|
+
first or the ``timeout`` expires.
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
| Wait For Image | success.png | error.png | 10 |
|
|
79
|
+
"""
|
|
80
|
+
return self.wait_for_multiple_images(
|
|
81
|
+
timeout, 0, [wanted_element], [not_wanted_element]
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
@keyword
|
|
85
|
+
def wait_for_multiple_images(
|
|
86
|
+
self, timeout, polling_interval, expected_images, not_expected_images
|
|
87
|
+
):
|
|
88
|
+
"""Waits until any of ``expected_images`` appears and returns it.
|
|
89
|
+
|
|
90
|
+
Fails if any of ``not_expected_images`` appears first or ``timeout``
|
|
91
|
+
expires. ``polling_interval`` is the time in seconds between checks
|
|
92
|
+
(``0`` uses the wait scan rate).
|
|
93
|
+
|
|
94
|
+
Example:
|
|
95
|
+
| @{wanted}= | Create List | done1.png | done2.png |
|
|
96
|
+
| @{unwanted}= | Create List | error1.png | name:Error |
|
|
97
|
+
| ${found}= | Wait For Multiple Images | 900 | 10 | ${wanted} | ${unwanted} |
|
|
98
|
+
"""
|
|
99
|
+
timeout = self.ctx.to_seconds(timeout)
|
|
100
|
+
interval = float(polling_interval) or (1.0 / self.ctx.scan_rate)
|
|
101
|
+
wanted = [(e, parse_locator(e)) for e in _as_list(expected_images)]
|
|
102
|
+
unwanted = [
|
|
103
|
+
(e, parse_locator(e)) for e in _as_list(not_expected_images)
|
|
104
|
+
]
|
|
105
|
+
deadline = time.monotonic() + timeout
|
|
106
|
+
while True:
|
|
107
|
+
for raw, loc in unwanted:
|
|
108
|
+
if self.ctx.find_now(loc):
|
|
109
|
+
raise AssertionError(
|
|
110
|
+
f"Not expected element '{loc.describe()}' appeared."
|
|
111
|
+
)
|
|
112
|
+
for raw, loc in wanted:
|
|
113
|
+
matches = self.ctx.find_now(loc)
|
|
114
|
+
if matches:
|
|
115
|
+
self.ctx.last_match = matches[0]
|
|
116
|
+
return raw
|
|
117
|
+
if time.monotonic() >= deadline:
|
|
118
|
+
names = ', '.join(loc.describe() for _, loc in wanted)
|
|
119
|
+
raise AssertionError(
|
|
120
|
+
f'None of the expected elements appeared: {names}.'
|
|
121
|
+
)
|
|
122
|
+
time.sleep(interval)
|
|
123
|
+
|
|
124
|
+
@keyword
|
|
125
|
+
def image_count(self, element) -> int:
|
|
126
|
+
"""Returns how many times ``element`` is currently found on screen.
|
|
127
|
+
|
|
128
|
+
Example:
|
|
129
|
+
| ${count}= | Image Count | checkbox.png |
|
|
130
|
+
| Should Be Equal As Integers | ${count} | 3 |
|
|
131
|
+
"""
|
|
132
|
+
return len(self.ctx.find_all(element))
|
|
133
|
+
|
|
134
|
+
@keyword
|
|
135
|
+
def get_match_score(self, element) -> float:
|
|
136
|
+
"""Returns the best match score (0-1) of ``element`` on screen.
|
|
137
|
+
|
|
138
|
+
For image locators the score is computed even when below the minimum
|
|
139
|
+
similarity. For UI locators returns ``1.0`` if found, otherwise ``0.0``.
|
|
140
|
+
|
|
141
|
+
Example:
|
|
142
|
+
| ${score}= | Get Match Score | maybe.png |
|
|
143
|
+
| IF | ${score} > 0.95 | Click | maybe.png |
|
|
144
|
+
"""
|
|
145
|
+
locator = parse_locator(element)
|
|
146
|
+
if locator.kind == 'image':
|
|
147
|
+
return self.ctx.image.best_score(
|
|
148
|
+
locator, self.ctx.search_region(locator)
|
|
149
|
+
)
|
|
150
|
+
return 1.0 if self.ctx.find_now(locator) else 0.0
|
|
151
|
+
|
|
152
|
+
@keyword
|
|
153
|
+
def get_image_coordinates(self, element, coordinates=None) -> list:
|
|
154
|
+
"""Returns ``[x, y, width, height]`` of ``element``.
|
|
155
|
+
|
|
156
|
+
If ``coordinates`` ``[x, y, w, h]`` is given, searches only there.
|
|
157
|
+
|
|
158
|
+
Examples:
|
|
159
|
+
| ${coords}= | Get Image Coordinates | image.png=0.75 |
|
|
160
|
+
| ${coords}= | Get Image Coordinates | image.png | [0, 0, 800, 600] |
|
|
161
|
+
| ${coords}= | Get Image Coordinates | automation_id:btnSave |
|
|
162
|
+
"""
|
|
163
|
+
from ..geometry import to_region
|
|
164
|
+
|
|
165
|
+
region = (
|
|
166
|
+
to_region(coordinates)
|
|
167
|
+
if coordinates not in (None, '', [])
|
|
168
|
+
else None
|
|
169
|
+
)
|
|
170
|
+
return self.ctx.find(element, region=region).to_list()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _as_list(value):
|
|
174
|
+
if value in (None, ''):
|
|
175
|
+
return []
|
|
176
|
+
if isinstance(value, (list, tuple)):
|
|
177
|
+
return list(value)
|
|
178
|
+
return [value]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from dataclasses import dataclass, field, replace
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .geometry import Match, Region, to_offset, to_region
|
|
6
|
+
|
|
7
|
+
IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.bmp')
|
|
8
|
+
|
|
9
|
+
# User facing key -> pywinauto find_elements criteria name
|
|
10
|
+
UI_KEY_ALIASES = {
|
|
11
|
+
'name': 'title',
|
|
12
|
+
'title': 'title',
|
|
13
|
+
'name_re': 'title_re',
|
|
14
|
+
'title_re': 'title_re',
|
|
15
|
+
'automation_id': 'auto_id',
|
|
16
|
+
'automationid': 'auto_id',
|
|
17
|
+
'auto_id': 'auto_id',
|
|
18
|
+
'id': 'auto_id',
|
|
19
|
+
'class': 'class_name',
|
|
20
|
+
'class_name': 'class_name',
|
|
21
|
+
'classname': 'class_name',
|
|
22
|
+
'class_name_re': 'class_name_re',
|
|
23
|
+
'control_type': 'control_type',
|
|
24
|
+
'type': 'control_type',
|
|
25
|
+
'handle': 'handle',
|
|
26
|
+
'process': 'process',
|
|
27
|
+
'found_index': 'found_index',
|
|
28
|
+
'index': 'found_index',
|
|
29
|
+
}
|
|
30
|
+
UI_PREFIXES = ('uia', 'win32')
|
|
31
|
+
_INT_KEYS = {'handle', 'process', 'found_index'}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Locator:
|
|
36
|
+
"""Normalized element locator.
|
|
37
|
+
|
|
38
|
+
``kind`` is ``image``, ``ui`` or ``region``.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
kind: str
|
|
42
|
+
images: list[str] = field(default_factory=list)
|
|
43
|
+
similarity: float | None = None
|
|
44
|
+
offset: tuple[int, int] = (0, 0)
|
|
45
|
+
region: Region | None = None
|
|
46
|
+
criteria: dict[str, Any] = field(default_factory=dict)
|
|
47
|
+
backend: str | None = None
|
|
48
|
+
fixed: Region | None = None
|
|
49
|
+
raw: Any = None
|
|
50
|
+
|
|
51
|
+
def with_region(self, region: Region | None) -> 'Locator':
|
|
52
|
+
return replace(self, region=region)
|
|
53
|
+
|
|
54
|
+
def with_extra_offset(self, x: int = 0, y: int = 0) -> 'Locator':
|
|
55
|
+
return replace(
|
|
56
|
+
self, offset=(self.offset[0] + int(x), self.offset[1] + int(y))
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def describe(self) -> str:
|
|
60
|
+
if self.kind == 'image':
|
|
61
|
+
text = ' | '.join(self.images)
|
|
62
|
+
elif self.kind == 'ui':
|
|
63
|
+
text = ' AND '.join(f'{k}:{v}' for k, v in self.criteria.items())
|
|
64
|
+
if self.backend:
|
|
65
|
+
text = f'{self.backend}:{text}'
|
|
66
|
+
else:
|
|
67
|
+
text = str(self.fixed)
|
|
68
|
+
return text
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _ui_criteria_from_string(text: str) -> dict[str, Any] | None:
|
|
72
|
+
parts = re.split(r'\s+AND\s+', text.strip())
|
|
73
|
+
criteria: dict[str, Any] = {}
|
|
74
|
+
for part in parts:
|
|
75
|
+
m = re.match(r'^\s*([A-Za-z_]+)\s*[:=]\s*(.*?)\s*$', part, re.S)
|
|
76
|
+
if not m or m.group(1).lower() not in UI_KEY_ALIASES:
|
|
77
|
+
return None
|
|
78
|
+
key = UI_KEY_ALIASES[m.group(1).lower()]
|
|
79
|
+
criteria[key] = _coerce_ui_value(key, m.group(2))
|
|
80
|
+
return criteria
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _coerce_ui_value(key: str, value: Any) -> Any:
|
|
84
|
+
if (
|
|
85
|
+
key in _INT_KEYS
|
|
86
|
+
and isinstance(value, str)
|
|
87
|
+
and value.strip().lstrip('-').isdigit()
|
|
88
|
+
):
|
|
89
|
+
return int(value)
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _split_similarity(path: str) -> tuple[str, float | None]:
|
|
94
|
+
"""Support Sikuli style ``image.png=0.85``."""
|
|
95
|
+
m = re.match(
|
|
96
|
+
r'^(.*\.(?:png|jpe?g|bmp))\s*=\s*([0-9]*\.?[0-9]+)$', path, re.I
|
|
97
|
+
)
|
|
98
|
+
if m:
|
|
99
|
+
return m.group(1), float(m.group(2))
|
|
100
|
+
return path, None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _normalize_similarity(value: Any) -> float | None:
|
|
104
|
+
if value is None or value == '':
|
|
105
|
+
return None
|
|
106
|
+
value = float(value)
|
|
107
|
+
if value > 1: # allow percent values such as 85
|
|
108
|
+
value = value / 100.0
|
|
109
|
+
if not 0 < value <= 1:
|
|
110
|
+
raise ValueError(f'Similarity must be between 0 and 1, got {value}.')
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_locator(value: Any) -> Locator:
|
|
115
|
+
"""Convert a keyword ``element`` argument into a :class:`Locator`."""
|
|
116
|
+
if isinstance(value, Locator):
|
|
117
|
+
return value
|
|
118
|
+
if isinstance(value, Match):
|
|
119
|
+
return Locator(
|
|
120
|
+
kind='region', fixed=value, offset=tuple(value.offset), raw=value
|
|
121
|
+
)
|
|
122
|
+
if isinstance(value, Region):
|
|
123
|
+
return Locator(kind='region', fixed=value, raw=value)
|
|
124
|
+
if isinstance(value, dict):
|
|
125
|
+
return _parse_dict(value)
|
|
126
|
+
if isinstance(value, (list, tuple)):
|
|
127
|
+
if len(value) == 4 and all(isinstance(v, (int, float)) for v in value):
|
|
128
|
+
return Locator(kind='region', fixed=to_region(value), raw=value)
|
|
129
|
+
return _parse_dict({'path': list(value)})
|
|
130
|
+
if value is None or str(value).strip() == '':
|
|
131
|
+
raise ValueError('Element locator cannot be empty.')
|
|
132
|
+
return _parse_string(str(value))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _parse_string(text: str) -> Locator:
|
|
136
|
+
stripped = text.strip()
|
|
137
|
+
lower = stripped.lower()
|
|
138
|
+
if lower.startswith('image:'):
|
|
139
|
+
path, sim = _split_similarity(stripped[6:].strip())
|
|
140
|
+
return Locator(kind='image', images=[path], similarity=sim, raw=text)
|
|
141
|
+
for prefix in UI_PREFIXES:
|
|
142
|
+
if lower.startswith(prefix + ':'):
|
|
143
|
+
criteria = _ui_criteria_from_string(stripped[len(prefix) + 1 :])
|
|
144
|
+
if not criteria:
|
|
145
|
+
raise ValueError(f"Invalid UI locator '{text}'.")
|
|
146
|
+
return Locator(
|
|
147
|
+
kind='ui', criteria=criteria, backend=prefix, raw=text
|
|
148
|
+
)
|
|
149
|
+
criteria = _ui_criteria_from_string(stripped)
|
|
150
|
+
if criteria:
|
|
151
|
+
return Locator(kind='ui', criteria=criteria, raw=text)
|
|
152
|
+
path, sim = _split_similarity(stripped)
|
|
153
|
+
return Locator(kind='image', images=[path], similarity=sim, raw=text)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _parse_dict(data: dict) -> Locator:
|
|
157
|
+
lower = {str(k).lower(): v for k, v in data.items()}
|
|
158
|
+
offset = to_offset(lower.get('offset'))
|
|
159
|
+
region = (
|
|
160
|
+
to_region(lower['region'])
|
|
161
|
+
if lower.get('region') not in (None, '')
|
|
162
|
+
else None
|
|
163
|
+
)
|
|
164
|
+
similarity = _normalize_similarity(lower.get('similarity'))
|
|
165
|
+
backend = lower.get('backend')
|
|
166
|
+
if backend is not None:
|
|
167
|
+
backend = str(backend).lower()
|
|
168
|
+
if backend not in UI_PREFIXES:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"Invalid backend '{backend}'. Use 'uia' or 'win32'."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if 'path' in lower:
|
|
174
|
+
paths = lower['path']
|
|
175
|
+
if isinstance(paths, str):
|
|
176
|
+
paths = [paths]
|
|
177
|
+
paths = [str(p) for p in paths]
|
|
178
|
+
if not paths:
|
|
179
|
+
raise ValueError("Locator 'path' cannot be empty.")
|
|
180
|
+
return Locator(
|
|
181
|
+
kind='image',
|
|
182
|
+
images=paths,
|
|
183
|
+
similarity=similarity,
|
|
184
|
+
offset=offset,
|
|
185
|
+
region=region,
|
|
186
|
+
raw=data,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if 'locator' in lower:
|
|
190
|
+
base = parse_locator(lower['locator'])
|
|
191
|
+
return replace(
|
|
192
|
+
base,
|
|
193
|
+
offset=(base.offset[0] + offset[0], base.offset[1] + offset[1]),
|
|
194
|
+
region=region or base.region,
|
|
195
|
+
similarity=similarity
|
|
196
|
+
if similarity is not None
|
|
197
|
+
else base.similarity,
|
|
198
|
+
backend=backend or base.backend,
|
|
199
|
+
raw=data,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
criteria = {}
|
|
203
|
+
for key, val in lower.items():
|
|
204
|
+
if key in UI_KEY_ALIASES:
|
|
205
|
+
mapped = UI_KEY_ALIASES[key]
|
|
206
|
+
criteria[mapped] = _coerce_ui_value(mapped, val)
|
|
207
|
+
if criteria:
|
|
208
|
+
return Locator(
|
|
209
|
+
kind='ui',
|
|
210
|
+
criteria=criteria,
|
|
211
|
+
backend=backend,
|
|
212
|
+
offset=offset,
|
|
213
|
+
region=region,
|
|
214
|
+
raw=data,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if region is not None:
|
|
218
|
+
return Locator(kind='region', fixed=region, offset=offset, raw=data)
|
|
219
|
+
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"Invalid element locator {data!r}. Use 'path' for images or UI keys such as "
|
|
222
|
+
f"'name', 'automation_id', 'class_name', 'control_type'."
|
|
223
|
+
)
|