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,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
+ )