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