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,187 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Region:
|
|
8
|
+
"""Rectangular area of the screen in virtual-screen coordinates."""
|
|
9
|
+
|
|
10
|
+
x: int
|
|
11
|
+
y: int
|
|
12
|
+
width: int
|
|
13
|
+
height: int
|
|
14
|
+
|
|
15
|
+
def __post_init__(self):
|
|
16
|
+
self.x, self.y = int(self.x), int(self.y)
|
|
17
|
+
self.width, self.height = int(self.width), int(self.height)
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def w(self) -> int:
|
|
21
|
+
return self.width
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def h(self) -> int:
|
|
25
|
+
return self.height
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def center(self) -> tuple[int, int]:
|
|
29
|
+
return self.x + self.width // 2, self.y + self.height // 2
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def right(self) -> int:
|
|
33
|
+
return self.x + self.width
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def bottom(self) -> int:
|
|
37
|
+
return self.y + self.height
|
|
38
|
+
|
|
39
|
+
def to_list(self) -> list[int]:
|
|
40
|
+
return [self.x, self.y, self.width, self.height]
|
|
41
|
+
|
|
42
|
+
def to_tuple(self) -> tuple[int, int, int, int]:
|
|
43
|
+
return self.x, self.y, self.width, self.height
|
|
44
|
+
|
|
45
|
+
def contains_point(self, px: int, py: int) -> bool:
|
|
46
|
+
return self.x <= px < self.right and self.y <= py < self.bottom
|
|
47
|
+
|
|
48
|
+
def intersection(self, other: 'Region') -> 'Region | None':
|
|
49
|
+
x1, y1 = max(self.x, other.x), max(self.y, other.y)
|
|
50
|
+
x2, y2 = min(self.right, other.right), min(self.bottom, other.bottom)
|
|
51
|
+
if x2 <= x1 or y2 <= y1:
|
|
52
|
+
return None
|
|
53
|
+
return Region(x1, y1, x2 - x1, y2 - y1)
|
|
54
|
+
|
|
55
|
+
def overlap_ratio(self, other: 'Region') -> float:
|
|
56
|
+
inter = self.intersection(other)
|
|
57
|
+
if inter is None:
|
|
58
|
+
return 0.0
|
|
59
|
+
smaller = (
|
|
60
|
+
min(self.width * self.height, other.width * other.height) or 1
|
|
61
|
+
)
|
|
62
|
+
return inter.width * inter.height / smaller
|
|
63
|
+
|
|
64
|
+
def extended(self, direction: str, times: float = 1) -> 'Region':
|
|
65
|
+
"""Region adjacent to this one in ``direction`` (``above``, ``below``,
|
|
66
|
+
``left``, ``right`` or ``original``) scaled by ``times``."""
|
|
67
|
+
direction = str(direction).strip().lower()
|
|
68
|
+
times = float(times)
|
|
69
|
+
if direction == 'original':
|
|
70
|
+
return Region(*self.to_tuple())
|
|
71
|
+
if direction == 'below':
|
|
72
|
+
return Region(self.x, self.bottom, self.width, self.height * times)
|
|
73
|
+
if direction == 'above':
|
|
74
|
+
h = int(self.height * times)
|
|
75
|
+
return Region(self.x, self.y - h, self.width, h)
|
|
76
|
+
if direction == 'right':
|
|
77
|
+
return Region(self.right, self.y, self.width * times, self.height)
|
|
78
|
+
if direction == 'left':
|
|
79
|
+
w = int(self.width * times)
|
|
80
|
+
return Region(self.x - w, self.y, w, self.height)
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"Invalid direction '{direction}'. Use above, below, left, right or original."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def jump(self, direction: str, jumps: int, margin: int = 1) -> 'Region':
|
|
86
|
+
"""Translate the region ``jumps`` times its own size plus ``margin``."""
|
|
87
|
+
direction = str(direction).strip().lower()
|
|
88
|
+
jumps, margin = int(jumps), int(margin)
|
|
89
|
+
if margin < 1:
|
|
90
|
+
raise ValueError('Margin must be >= 1.')
|
|
91
|
+
dx = (self.width + margin) * jumps
|
|
92
|
+
dy = (self.height + margin) * jumps
|
|
93
|
+
moves = {
|
|
94
|
+
'below': (0, dy),
|
|
95
|
+
'above': (0, -dy),
|
|
96
|
+
'right': (dx, 0),
|
|
97
|
+
'left': (-dx, 0),
|
|
98
|
+
}
|
|
99
|
+
if direction not in moves:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Invalid direction '{direction}'. Use above, below, left or right."
|
|
102
|
+
)
|
|
103
|
+
mx, my = moves[direction]
|
|
104
|
+
return Region(self.x + mx, self.y + my, self.width, self.height)
|
|
105
|
+
|
|
106
|
+
def __str__(self) -> str:
|
|
107
|
+
return f'Region(x={self.x}, y={self.y}, width={self.width}, height={self.height})'
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class Match(Region):
|
|
112
|
+
"""Element found on screen. ``target`` is the action point
|
|
113
|
+
(center plus the locator offset)."""
|
|
114
|
+
|
|
115
|
+
score: float = 1.0
|
|
116
|
+
source: str = ''
|
|
117
|
+
offset: tuple[int, int] = (0, 0)
|
|
118
|
+
element: Any = field(default=None, repr=False, compare=False)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def target(self) -> tuple[int, int]:
|
|
122
|
+
cx, cy = self.center
|
|
123
|
+
return cx + int(self.offset[0]), cy + int(self.offset[1])
|
|
124
|
+
|
|
125
|
+
def __str__(self) -> str:
|
|
126
|
+
return (
|
|
127
|
+
f'Match(x={self.x}, y={self.y}, width={self.width}, height={self.height}, '
|
|
128
|
+
f'score={self.score:.3f}, source={self.source})'
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def sort_matches(matches: list, sort_by: str = 'row') -> list:
|
|
133
|
+
"""Sorts matches by ``row`` (top-bottom, left-right), ``column``
|
|
134
|
+
(left-right, top-bottom), ``score`` (best first) or ``none``."""
|
|
135
|
+
key = str(sort_by or 'none').strip().lower()
|
|
136
|
+
if key == 'row':
|
|
137
|
+
return sorted(matches, key=lambda m: (m.y, m.x))
|
|
138
|
+
if key == 'column':
|
|
139
|
+
return sorted(matches, key=lambda m: (m.x, m.y))
|
|
140
|
+
if key == 'score':
|
|
141
|
+
return sorted(matches, key=lambda m: -getattr(m, 'score', 1.0))
|
|
142
|
+
if key == 'none':
|
|
143
|
+
return list(matches)
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"Invalid sort_by '{sort_by}'. Use row, column, score or none."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def to_region(value: Any) -> Region:
|
|
150
|
+
"""Convert a Region, list/tuple ``[x, y, w, h]``, dict or string into a Region."""
|
|
151
|
+
if isinstance(value, Region):
|
|
152
|
+
return Region(value.x, value.y, value.width, value.height)
|
|
153
|
+
if isinstance(value, dict):
|
|
154
|
+
lower = {str(k).lower(): v for k, v in value.items()}
|
|
155
|
+
return Region(
|
|
156
|
+
lower.get('x', 0),
|
|
157
|
+
lower.get('y', 0),
|
|
158
|
+
lower.get('width', lower.get('w')),
|
|
159
|
+
lower.get('height', lower.get('h')),
|
|
160
|
+
)
|
|
161
|
+
if isinstance(value, str):
|
|
162
|
+
text = value.strip()
|
|
163
|
+
if not text.startswith(('[', '(')):
|
|
164
|
+
text = f'[{text}]'
|
|
165
|
+
value = ast.literal_eval(text)
|
|
166
|
+
if isinstance(value, (list, tuple)) and len(value) == 4:
|
|
167
|
+
return Region(*[int(float(v)) for v in value])
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"Cannot convert '{value}' to a region. Expected [x, y, width, height]."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def to_offset(value: Any) -> tuple[int, int]:
|
|
174
|
+
if value is None or value == '':
|
|
175
|
+
return 0, 0
|
|
176
|
+
if isinstance(value, dict):
|
|
177
|
+
lower = {str(k).lower(): v for k, v in value.items()}
|
|
178
|
+
return int(lower.get('x', 0)), int(lower.get('y', 0))
|
|
179
|
+
if isinstance(value, str):
|
|
180
|
+
value = ast.literal_eval(
|
|
181
|
+
value if value.strip().startswith(('[', '(')) else f'[{value}]'
|
|
182
|
+
)
|
|
183
|
+
if isinstance(value, (list, tuple)) and len(value) == 2:
|
|
184
|
+
return int(value[0]), int(value[1])
|
|
185
|
+
raise ValueError(
|
|
186
|
+
f"Cannot convert '{value}' to an offset. Expected {{'x': int, 'y': int}}."
|
|
187
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .application import ApplicationKeywords
|
|
2
|
+
from .config import ConfigKeywords
|
|
3
|
+
from .cursor import CursorKeywords
|
|
4
|
+
from .element import ElementKeywords
|
|
5
|
+
from .find import FindKeywords
|
|
6
|
+
from .keyboard import KeyboardKeywords
|
|
7
|
+
from .mouse import MouseKeywords
|
|
8
|
+
from .ocr import OcrKeywords
|
|
9
|
+
from .region import RegionKeywords
|
|
10
|
+
from .wait import WaitKeywords
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
'ApplicationKeywords',
|
|
14
|
+
'ConfigKeywords',
|
|
15
|
+
'CursorKeywords',
|
|
16
|
+
'ElementKeywords',
|
|
17
|
+
'FindKeywords',
|
|
18
|
+
'KeyboardKeywords',
|
|
19
|
+
'MouseKeywords',
|
|
20
|
+
'OcrKeywords',
|
|
21
|
+
'RegionKeywords',
|
|
22
|
+
'WaitKeywords',
|
|
23
|
+
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
from robot.api import logger
|
|
5
|
+
from robot.api.deco import keyword
|
|
6
|
+
|
|
7
|
+
from ..locator import parse_locator
|
|
8
|
+
from .base import KeywordGroup
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ApplicationKeywords(KeywordGroup):
|
|
12
|
+
@keyword
|
|
13
|
+
def open_application(
|
|
14
|
+
self,
|
|
15
|
+
path: str,
|
|
16
|
+
*args,
|
|
17
|
+
alias: str = '',
|
|
18
|
+
backend: str = '',
|
|
19
|
+
timeout='10s',
|
|
20
|
+
):
|
|
21
|
+
"""Starts an application and returns its alias.
|
|
22
|
+
|
|
23
|
+
The application is started with pywinauto, so it can later be used
|
|
24
|
+
with UI Automation keywords. ``alias`` defaults to the executable name.
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
| Open Application | notepad.exe |
|
|
28
|
+
| Open Application | C:/apps/MyApp.exe | --config | test.ini | alias=myapp |
|
|
29
|
+
| Open Application | calc.exe | backend=uia |
|
|
30
|
+
"""
|
|
31
|
+
command = subprocess.list2cmdline([path, *args]) if args else path
|
|
32
|
+
name = alias or _exe_name(path)
|
|
33
|
+
self.ctx.ui.start(
|
|
34
|
+
name, command, backend or None, self.ctx.to_seconds(timeout)
|
|
35
|
+
)
|
|
36
|
+
logger.info(f"Started '{command}' as '{name}'.")
|
|
37
|
+
return name
|
|
38
|
+
|
|
39
|
+
@keyword
|
|
40
|
+
def connect_to_application(
|
|
41
|
+
self, alias: str = '', backend: str = '', timeout='10s', **criteria
|
|
42
|
+
):
|
|
43
|
+
"""Connects to a running application. Criteria are passed to
|
|
44
|
+
pywinauto ``Application.connect``: ``process``, ``path``, ``title``,
|
|
45
|
+
``title_re``, ``handle``.
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
| Connect To Application | process=1234 |
|
|
49
|
+
| Connect To Application | alias=calc | path=calc.exe |
|
|
50
|
+
| Connect To Application | title_re=.*Notepad |
|
|
51
|
+
"""
|
|
52
|
+
for key in ('process', 'handle'):
|
|
53
|
+
if key in criteria:
|
|
54
|
+
criteria[key] = int(criteria[key])
|
|
55
|
+
name = alias or str(
|
|
56
|
+
criteria.get('path') or criteria.get('title') or 'app'
|
|
57
|
+
)
|
|
58
|
+
self.ctx.ui.connect(
|
|
59
|
+
name, backend or None, self.ctx.to_seconds(timeout), **criteria
|
|
60
|
+
)
|
|
61
|
+
return name
|
|
62
|
+
|
|
63
|
+
@keyword
|
|
64
|
+
def close_application(self, name: str):
|
|
65
|
+
"""Closes an application by alias (from `Open Application` /
|
|
66
|
+
`Connect To Application`) or by process name.
|
|
67
|
+
|
|
68
|
+
Examples:
|
|
69
|
+
| Close Application | myapp |
|
|
70
|
+
| Close Application | notepad.exe |
|
|
71
|
+
"""
|
|
72
|
+
app = self.ctx.ui.apps.pop(name, None)
|
|
73
|
+
if app is not None:
|
|
74
|
+
try:
|
|
75
|
+
app.kill(soft=True)
|
|
76
|
+
except Exception:
|
|
77
|
+
app.kill()
|
|
78
|
+
return
|
|
79
|
+
exe = name if name.lower().endswith('.exe') else name + '.exe'
|
|
80
|
+
result = subprocess.run(
|
|
81
|
+
['taskkill', '/IM', exe, '/F'], capture_output=True, text=True
|
|
82
|
+
)
|
|
83
|
+
if result.returncode != 0:
|
|
84
|
+
raise AssertionError(
|
|
85
|
+
f"Could not close '{name}': {result.stderr.strip()}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# ---- windows -------------------------------------------------------------
|
|
89
|
+
@keyword
|
|
90
|
+
def attach_to_window(self, window, timeout=''):
|
|
91
|
+
"""Sets the top level window used as root for UI Automation searches
|
|
92
|
+
and brings it to the foreground.
|
|
93
|
+
|
|
94
|
+
``window`` uses the UI locator syntax (see `Element locators`).
|
|
95
|
+
|
|
96
|
+
Examples:
|
|
97
|
+
| Attach To Window | name:Untitled - Notepad |
|
|
98
|
+
| Attach To Window | name_re:.*Notepad |
|
|
99
|
+
| Attach To Window | win32:class:Notepad |
|
|
100
|
+
| Attach To Window | ${{ {"name": "Sample App", "backend": "uia"} }} |
|
|
101
|
+
"""
|
|
102
|
+
locator = parse_locator(window)
|
|
103
|
+
if locator.kind != 'ui':
|
|
104
|
+
raise ValueError(
|
|
105
|
+
"Attach To Window requires a UI locator such as 'name:My App'."
|
|
106
|
+
)
|
|
107
|
+
secs = self.ctx.to_seconds(timeout, self.ctx.timeout)
|
|
108
|
+
self.ctx.ui.attach_window(locator.criteria, locator.backend, secs)
|
|
109
|
+
|
|
110
|
+
@keyword
|
|
111
|
+
def switch_window(self, window, timeout=''):
|
|
112
|
+
"""Alias for `Attach To Window`.
|
|
113
|
+
|
|
114
|
+
Example:
|
|
115
|
+
| Switch Window | name:Settings |
|
|
116
|
+
"""
|
|
117
|
+
self.attach_to_window(window, timeout)
|
|
118
|
+
|
|
119
|
+
@keyword
|
|
120
|
+
def detach_window(self):
|
|
121
|
+
"""Removes the window set by `Attach To Window`; UI searches start
|
|
122
|
+
from the desktop again.
|
|
123
|
+
|
|
124
|
+
Example:
|
|
125
|
+
| Detach Window |
|
|
126
|
+
"""
|
|
127
|
+
self.ctx.ui.detach()
|
|
128
|
+
|
|
129
|
+
@keyword
|
|
130
|
+
def set_backend(self, backend: str):
|
|
131
|
+
"""Sets the default UI backend: ``uia`` (WPF, WinForms, UWP, Win32)
|
|
132
|
+
or ``win32`` (legacy Win32/WinForms). Returns the previous one.
|
|
133
|
+
|
|
134
|
+
Example:
|
|
135
|
+
| ${old}= | Set Backend | win32 |
|
|
136
|
+
"""
|
|
137
|
+
backend = str(backend).lower()
|
|
138
|
+
if backend not in ('uia', 'win32'):
|
|
139
|
+
raise ValueError("Backend must be 'uia' or 'win32'.")
|
|
140
|
+
old, self.ctx.ui.backend = self.ctx.ui.backend, backend
|
|
141
|
+
return old
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _exe_name(path: str) -> str:
|
|
145
|
+
try:
|
|
146
|
+
first = shlex.split(path, posix=False)[0]
|
|
147
|
+
except ValueError:
|
|
148
|
+
first = path
|
|
149
|
+
return first.strip('"').replace('\\', '/').rsplit('/', 1)[-1]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import inspect
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import pyautogui
|
|
6
|
+
|
|
7
|
+
from ..context import LibraryContext
|
|
8
|
+
from ..geometry import Match
|
|
9
|
+
|
|
10
|
+
_BUSY_DOC = """
|
|
11
|
+
|
|
12
|
+
After the action, waits while the cursor is busy (see `Busy cursor
|
|
13
|
+
handling`). With ``ignore_busy_error=True`` a busy cursor timeout only
|
|
14
|
+
logs a warning instead of failing.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def busy_action(func):
|
|
19
|
+
"""Adds the named-only ``ignore_busy_error`` argument and waits for a
|
|
20
|
+
busy cursor caused by the action after the keyword body runs."""
|
|
21
|
+
|
|
22
|
+
@functools.wraps(func)
|
|
23
|
+
def wrapper(self, *args, ignore_busy_error: bool = False, **kwargs):
|
|
24
|
+
with self._action(ignore_busy_error):
|
|
25
|
+
return func(self, *args, **kwargs)
|
|
26
|
+
|
|
27
|
+
sig = inspect.signature(func)
|
|
28
|
+
params = list(sig.parameters.values())
|
|
29
|
+
extra = inspect.Parameter(
|
|
30
|
+
'ignore_busy_error',
|
|
31
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
32
|
+
default=False,
|
|
33
|
+
annotation=bool,
|
|
34
|
+
)
|
|
35
|
+
insert_at = len(params)
|
|
36
|
+
if params and params[-1].kind is inspect.Parameter.VAR_KEYWORD:
|
|
37
|
+
insert_at -= 1
|
|
38
|
+
params.insert(insert_at, extra)
|
|
39
|
+
wrapper.__signature__ = sig.replace(parameters=params)
|
|
40
|
+
wrapper.__annotations__ = {
|
|
41
|
+
**getattr(func, '__annotations__', {}),
|
|
42
|
+
'ignore_busy_error': bool,
|
|
43
|
+
}
|
|
44
|
+
del wrapper.__wrapped__
|
|
45
|
+
wrapper.__doc__ = (func.__doc__ or '').rstrip() + _BUSY_DOC
|
|
46
|
+
return wrapper
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class KeywordGroup:
|
|
50
|
+
def __init__(self, ctx: LibraryContext):
|
|
51
|
+
self.ctx = ctx
|
|
52
|
+
|
|
53
|
+
def _locate(self, element, x_offset=0, y_offset=0, timeout=None) -> Match:
|
|
54
|
+
match = self.ctx.find(
|
|
55
|
+
element, timeout, x_offset=int(x_offset), y_offset=int(y_offset)
|
|
56
|
+
)
|
|
57
|
+
self.ctx.show_action(match)
|
|
58
|
+
return match
|
|
59
|
+
|
|
60
|
+
def _move(self, x: int, y: int):
|
|
61
|
+
self.ctx.cursor.auto_wait()
|
|
62
|
+
pyautogui.moveTo(x, y, duration=self.ctx.move_mouse_delay)
|
|
63
|
+
|
|
64
|
+
def _click_at(self, match: Match, clicks: int = 1, button: str = 'left'):
|
|
65
|
+
x, y = match.target
|
|
66
|
+
self._move(x, y)
|
|
67
|
+
self.ctx.cursor.auto_wait()
|
|
68
|
+
pyautogui.click(x, y, clicks=clicks, interval=0.05, button=button)
|
|
69
|
+
self.ctx.last_match = match
|
|
70
|
+
|
|
71
|
+
def _ready(self):
|
|
72
|
+
"""Waits for a busy cursor before keyboard/mouse actions without element."""
|
|
73
|
+
self.ctx.cursor.auto_wait()
|
|
74
|
+
|
|
75
|
+
def _action(self, ignore_busy_error=False):
|
|
76
|
+
"""Context manager for action keywords: waits for a busy cursor caused
|
|
77
|
+
by the action and optionally turns busy timeouts into warnings."""
|
|
78
|
+
if not isinstance(ignore_busy_error, bool):
|
|
79
|
+
ignore_busy_error = str(ignore_busy_error).strip().lower() in (
|
|
80
|
+
'true',
|
|
81
|
+
'1',
|
|
82
|
+
'yes',
|
|
83
|
+
'on',
|
|
84
|
+
)
|
|
85
|
+
return self.ctx.cursor.action(ignore_busy_error)
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _sleep(seconds: float):
|
|
89
|
+
if seconds > 0:
|
|
90
|
+
time.sleep(seconds)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import pyautogui
|
|
2
|
+
from robot.api.deco import keyword
|
|
3
|
+
|
|
4
|
+
from .base import KeywordGroup
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ConfigKeywords(KeywordGroup):
|
|
8
|
+
@keyword
|
|
9
|
+
def add_image_path(self, path: str):
|
|
10
|
+
"""Adds a folder where relative image paths are searched.
|
|
11
|
+
|
|
12
|
+
Folders are searched in the order they were added, then the current
|
|
13
|
+
working directory. Folders can also be given on import with ``image_paths``.
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
| Add Image Path | ${CURDIR}/images |
|
|
17
|
+
| Click | buttons/ok.png | | # resolved as ${CURDIR}/images/buttons/ok.png |
|
|
18
|
+
"""
|
|
19
|
+
self.ctx.resolver.add(path)
|
|
20
|
+
|
|
21
|
+
@keyword
|
|
22
|
+
def remove_image_path(self, path: str):
|
|
23
|
+
"""Removes a folder added with `Add Image Path`.
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
| Remove Image Path | ${CURDIR}/images |
|
|
27
|
+
"""
|
|
28
|
+
self.ctx.resolver.remove(path)
|
|
29
|
+
|
|
30
|
+
@keyword
|
|
31
|
+
def get_image_paths(self) -> list:
|
|
32
|
+
"""Returns the registered image folders.
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
| @{paths}= | Get Image Paths |
|
|
36
|
+
"""
|
|
37
|
+
return [str(p) for p in self.ctx.resolver.paths]
|
|
38
|
+
|
|
39
|
+
@keyword
|
|
40
|
+
def set_min_similarity(self, min_similarity: float) -> float:
|
|
41
|
+
"""Sets the default image similarity (0-1, default ``0.6``) and returns
|
|
42
|
+
the previous value. Locator level ``similarity`` overrides it.
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
| Set Min Similarity | 0.85 |
|
|
46
|
+
"""
|
|
47
|
+
value = float(min_similarity)
|
|
48
|
+
if value > 1:
|
|
49
|
+
value /= 100
|
|
50
|
+
if not 0 < value <= 1:
|
|
51
|
+
raise ValueError('Similarity must be between 0 and 1.')
|
|
52
|
+
old, self.ctx.similarity = self.ctx.similarity, value
|
|
53
|
+
return old
|
|
54
|
+
|
|
55
|
+
@keyword
|
|
56
|
+
def set_timeout(self, timeout) -> float:
|
|
57
|
+
"""Sets the default wait timeout and returns the previous value.
|
|
58
|
+
Accepts Robot Framework time format.
|
|
59
|
+
|
|
60
|
+
Examples:
|
|
61
|
+
| Set Timeout | 10 |
|
|
62
|
+
| ${old}= | Set Timeout | 2 min |
|
|
63
|
+
"""
|
|
64
|
+
old, self.ctx.timeout = self.ctx.timeout, self.ctx.to_seconds(timeout)
|
|
65
|
+
return old
|
|
66
|
+
|
|
67
|
+
@keyword
|
|
68
|
+
def set_capture_folder(self, path: str):
|
|
69
|
+
"""Sets the folder where captures are saved (default ``${OUTPUT DIR}``).
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
| Set Capture Folder | ${OUTPUT DIR}/captures |
|
|
73
|
+
"""
|
|
74
|
+
self.ctx.capture_folder = path
|
|
75
|
+
|
|
76
|
+
@keyword
|
|
77
|
+
def set_capture_matched_image(self, value: bool):
|
|
78
|
+
"""When enabled, every match is captured and embedded in the log.
|
|
79
|
+
Disabled by default.
|
|
80
|
+
|
|
81
|
+
Example:
|
|
82
|
+
| Set Capture Matched Image | true |
|
|
83
|
+
"""
|
|
84
|
+
self.ctx.capture_matched_image = _bool(value)
|
|
85
|
+
|
|
86
|
+
@keyword
|
|
87
|
+
def set_move_mouse_delay(self, delay: float):
|
|
88
|
+
"""Sets the duration (seconds) of mouse movements.
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
| Set Move Mouse Delay | 0.3 |
|
|
92
|
+
"""
|
|
93
|
+
self.ctx.move_mouse_delay = float(delay)
|
|
94
|
+
|
|
95
|
+
@keyword
|
|
96
|
+
def set_slow_motion_delay(self, delay: float):
|
|
97
|
+
"""Sets the duration (seconds) of the visual effect shown when
|
|
98
|
+
`Set Show Actions` is enabled.
|
|
99
|
+
|
|
100
|
+
Example:
|
|
101
|
+
| Set Slow Motion Delay | 1 |
|
|
102
|
+
"""
|
|
103
|
+
self.ctx.slow_motion_delay = float(delay)
|
|
104
|
+
|
|
105
|
+
@keyword
|
|
106
|
+
def set_show_actions(self, show_actions: bool):
|
|
107
|
+
"""Highlights each target before acting on it (useful for debugging).
|
|
108
|
+
|
|
109
|
+
Example:
|
|
110
|
+
| Set Show Actions | true |
|
|
111
|
+
"""
|
|
112
|
+
self.ctx.show_actions = _bool(show_actions)
|
|
113
|
+
|
|
114
|
+
@keyword
|
|
115
|
+
def set_wait_scan_rate(self, rate: float):
|
|
116
|
+
"""Sets how many searches per second are made while waiting (default 10).
|
|
117
|
+
|
|
118
|
+
Example:
|
|
119
|
+
| Set Wait Scan Rate | 5 |
|
|
120
|
+
"""
|
|
121
|
+
rate = float(rate)
|
|
122
|
+
if rate <= 0:
|
|
123
|
+
raise ValueError('Scan rate must be greater than 0.')
|
|
124
|
+
self.ctx.scan_rate = rate
|
|
125
|
+
|
|
126
|
+
@keyword
|
|
127
|
+
def set_always_resize(self, resize: float):
|
|
128
|
+
"""Resizes all reference images by ``resize`` before searching
|
|
129
|
+
(e.g. ``1.25`` for 125% DPI scaling). ``0`` or ``1`` disables it.
|
|
130
|
+
|
|
131
|
+
Example:
|
|
132
|
+
| Set Always Resize | 1.25 |
|
|
133
|
+
"""
|
|
134
|
+
self.ctx.image.resize_factor = float(resize)
|
|
135
|
+
|
|
136
|
+
@keyword
|
|
137
|
+
def set_grayscale(self, grayscale: bool):
|
|
138
|
+
"""Enables grayscale image matching (faster, ignores colors).
|
|
139
|
+
|
|
140
|
+
Example:
|
|
141
|
+
| Set Grayscale | true |
|
|
142
|
+
"""
|
|
143
|
+
self.ctx.image.grayscale = _bool(grayscale)
|
|
144
|
+
|
|
145
|
+
@keyword
|
|
146
|
+
def set_fail_safe(self, enabled: bool):
|
|
147
|
+
"""Enables/disables pyautogui fail-safe (moving the mouse to a screen
|
|
148
|
+
corner aborts the action). Enabled by default.
|
|
149
|
+
|
|
150
|
+
Example:
|
|
151
|
+
| Set Fail Safe | false |
|
|
152
|
+
"""
|
|
153
|
+
pyautogui.FAILSAFE = _bool(enabled)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _bool(value) -> bool:
|
|
157
|
+
if isinstance(value, bool):
|
|
158
|
+
return value
|
|
159
|
+
return str(value).strip().lower() in ('true', '1', 'yes', 'on')
|