diff --git a/docs/source/libraries/desktop_windows/index.rst b/docs/source/libraries/desktop_windows/index.rst deleted file mode 100644 index 21641dffec..0000000000 --- a/docs/source/libraries/desktop_windows/index.rst +++ /dev/null @@ -1,22 +0,0 @@ -############### -Desktop.Windows -############### - -Automate Windows desktop applications. - -.. toctree:: - :maxdepth: 1 - :hidden: - - python - -Keywords -======== - -🔗 Direct link to `keyword documentation <../../libdoc/RPA_Desktop_Windows.html>`_. - -------- - -.. raw:: html - - diff --git a/docs/source/libraries/desktop_windows/python.rst b/docs/source/libraries/desktop_windows/python.rst deleted file mode 100644 index 3c7aa423f5..0000000000 --- a/docs/source/libraries/desktop_windows/python.rst +++ /dev/null @@ -1,12 +0,0 @@ -########## -Python API -########## - -******** -Windows -******** - -.. autoclass:: RPA.Desktop.Windows.Windows - :members: - :inherited-members: - :undoc-members: diff --git a/docs/source/releasenotes.rst b/docs/source/releasenotes.rst index 65d74ff5ab..41b5be0507 100644 --- a/docs/source/releasenotes.rst +++ b/docs/source/releasenotes.rst @@ -12,17 +12,32 @@ Latest versions `Upcoming release `_ +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- **Breaking:** Remove the long-deprecated ``RPA.Desktop.Windows`` library (pywinauto-based). + It has emitted a deprecation warning pointing to ``RPA.Windows`` since being deprecated in + favor of it, and is no longer maintained. Use ``RPA.Windows`` for all Windows UI automation + going forward (fixes :issue:`1322`). - **Security:** ``RPA.Archive``: Fix a Zip Slip path traversal vulnerability (CWE-22) in ``Extract Archive`` — archive members with path traversal sequences (e.g. ``../../evil.py``) could previously be extracted outside the requested destination directory. Extraction now validates that every member resolves within the destination before extracting, raising ``ValueError`` otherwise (fixes :issue:`1339`, :issue:`1340`). - +- ``rpaframework-core``: Fix the Windows locator parser silently mis-tokenizing a strategy + when the locator value contained a stray ``locator=`` prefix or an unmatched quote + character (e.g. produced a bogus ``locator='executable`` strategy instead of recognizing + ``executable:``), with a clearer warning surfaced when this happens (fixes :issue:`1323`). +- ``RPA.Desktop``: ``Highlight Elements`` now returns the list of matched element regions + instead of ``None``, exposing coordinates that were already being computed internally + (fixes :issue:`1324`). +- ``rpaframework-core``: Match ``executable:`` locators case-insensitively. Windows file + names are case-insensitive, but the comparison was not, so ``executable:notepad.exe`` + could not find a process that Windows lists as ``Notepad.exe`` — as it does on Windows 11. + ``handle:`` matching is numeric and is unchanged. - **Security:** Bump ``soupsieve`` ≥2.8.4 (HIGH — memory exhaustion via large comma-separated selector lists, CVE-2026-49476) in the root and ``rpaframework`` lock files. -- ``rpaframework`` **32.0.2** +- ``rpaframework-core`` **13.0.2** +- ``rpaframework`` **33.0.0** `Released `_ ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ diff --git a/examples/README.md b/examples/README.md index 047503a080..2b821ccb38 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,13 +3,3 @@ ## manipulating-pdf Python syntax example of working with PDFs. - -## windows-basics - -Python and RFW syntax example of Windows basic operations with Calculator and Notepad. - -## windows-uidemo-application - -Python syntax example of UIPath's UIDemo.exe - -The environment variable `UIDEMO_EXE` needs to be set to the absolute path of the file. diff --git a/examples/windows-basics/mytextfile.txt b/examples/windows-basics/mytextfile.txt deleted file mode 100644 index a2c76e4c17..0000000000 --- a/examples/windows-basics/mytextfile.txt +++ /dev/null @@ -1 +0,0 @@ -Story of the Windows RPA \ No newline at end of file diff --git a/examples/windows-basics/task.py b/examples/windows-basics/task.py deleted file mode 100644 index 552e61dce5..0000000000 --- a/examples/windows-basics/task.py +++ /dev/null @@ -1,93 +0,0 @@ -""" Windows Calculator robot. """ -import logging -from pathlib import Path -import sys -from time import sleep - -from RPA.Desktop.Windows import Windows - -library = None -stdout = logging.StreamHandler(sys.stdout) - -logging.basicConfig( - level=logging.DEBUG, - format="[{%(filename)s:%(lineno)d} %(levelname)s - %(message)s", - handlers=[stdout], -) - -LOGGER = logging.getLogger(__name__) - - -def result_should_be(expected): - element = library.get_element("CalculatorResults") - value = int(element["rich_text"].replace("Display is ", "")) - LOGGER.info("Got %s value, expected value %s" % (value, expected)) - assert value == expected - - -def using_mouse(): - library.mouse_click("One") - library.mouse_click("Plus") - library.mouse_click("Five") - library.mouse_click("Equals") - result_should_be(6) - - -def using_keys(): - library.send_keys("320{+}480{=}") - result_should_be(800) - - -def open_navigation(navigation_item): - library.wait_for_element("Open Navigation") - library.mouse_click("Open Navigation") - library.refresh_window() - library.mouse_click(navigation_item) - library.refresh_window() - - -def open_calculator(): - library.open_executable("calc.exe", "Calculator") - open_navigation("Standard Calculator") - controls, elements = library.get_window_elements() - LOGGER.info("Printing elements") - for elem in elements: - LOGGER.info(elem) - LOGGER.info("Printing controls") - for ctrl in controls: - LOGGER.info(ctrl) - using_mouse() - library.mouse_click("Clear") - using_keys() - open_navigation("Date Calculation Calculator") - sleep(1) - - -def minimize_maximize(windowtitle): - library.minimize_dialog() - sleep(1) - library.restore_dialog() - sleep(1) - library.minimize_dialog() - sleep(1) - library.restore_dialog(windowtitle) - - -def open_text_file(): - filepath = Path(__file__).parent / "mytextfile.txt" - library.open_file(filepath.absolute(), "Notepad", wildcard=True) - element = library.get_element("class:Edit") - assert element["legacy"]["Value"] == "Story of the Windows RPA" - - -if __name__ == "__main__": - library = Windows() - try: - open_text_file() - open_calculator() - minimize_maximize("Calculator") - open_navigation("Standard Calculator") - library.mouse_click("Clear") - sleep(3) - finally: - library.close_all_applications() diff --git a/examples/windows-basics/task.robot b/examples/windows-basics/task.robot deleted file mode 100644 index ecfd8b6838..0000000000 --- a/examples/windows-basics/task.robot +++ /dev/null @@ -1,28 +0,0 @@ -*** Settings *** -Library RPA.Desktop.Windows -Task Teardown Close All Applications - -*** Keywords *** -Connect to Calculator - [Arguments] ${handle} - Connect By Handle ${${handle}} existing_app=True - Send Keys %2 - Sleep 2s - Send Keys %1 - Sleep 2s - -*** Tasks *** -Use calculator - Open Executable calc.exe Calculator - ${controls} ${elements}= Get Window Elements - FOR ${elem} IN @{elements} - Run Keyword If "${elem}[control_type]" == "Window" and "${elem}[name]" == "Calculator" and "${elem}[class_name]" == "ApplicationFrameWindow" - ... Connect To Calculator ${elem}[handle] - END - -Use Notepad - ${app}= Open From Search notepad.exe Notepad wildcard=True - Log Many ${app} - Open Dialog Notepad wildcard=True existing_app=True - ${process}= Process ID Exists 25032 - Run Keyword If ${process} Log Process exists diff --git a/examples/windows-uidemo-application/task.py b/examples/windows-uidemo-application/task.py deleted file mode 100644 index 26b257ff5e..0000000000 --- a/examples/windows-uidemo-application/task.py +++ /dev/null @@ -1,108 +0,0 @@ -""" An example robot. """ -import logging -import os -from pathlib import Path -import sys -from time import sleep - -from RPA.Desktop.Windows import Windows -from variables import CURRENT_DATE, CURRENT_TIME, CASH_IN, ON_US, NOT_US - - -application_path = Path(os.getenv("UIDEMO_EXE")) -if not os.path.exists(application_path): - raise ValueError("Set path to UIDemo.exe with environment variable UIDEMO_EXE") - -library = None -stdout = logging.StreamHandler(sys.stdout) - -logging.basicConfig( - level=logging.DEBUG, - format="[{%(filename)s:%(lineno)d} %(levelname)s - %(message)s", - handlers=[stdout], -) - -LOGGER = logging.getLogger(__name__) - - -def set_slider_value(locator, slidervalue): - element, _ = library.find_element(locator) - if element and len(element) == 1: - target_element = element[0] - else: - raise ValueError("Did not find unique element") - left, top, right, bottom = library._get_element_coordinates(target_element) - width = right - left - middle = top + int((bottom - top) / 2) - point = left + int(width * slidervalue) - library.mouse_click_coords(point, middle) - - -def do_the_application_login(): - library.open_executable(r"%s" % application_path, "UiDemo") - library.type_into("user", "admin") - library.type_into("pass", "password") - library.mouse_click("name:'Log In' and type:Button") - library.open_dialog("UIDemo") - - -def click_transactions(): - library.mouse_click("name:'Split Deposit' and type:RadioButton") - library.mouse_click("name:Withdrawal and type:RadioButton") - library.mouse_click("name:Deposit and type:RadioButton") - - -def click_configurations(): - library.mouse_click("name:'Use Cash Count' and type:CheckBox", focus="topleft") - library.mouse_click("name:'Use Both' and type:RadioButton", focus="topleft") - library.mouse_click("name:'Use Amount' and type:RadioButton", focus="topleft") - library.mouse_click("name:'Use Piece Count' and type:RadioButton", focus="topleft") - library.mouse_click( - "name:'Reverse Denomination' and type:CheckBox", focus="topleft" - ) - library.mouse_click("name:'Eliminate $2' and type:CheckBox", focus="topleft") - - -def click_settings(): - library.mouse_click("name:GraphLabel and type:CheckBox") - library.mouse_click("name:TrainingTip and type:CheckBox") - library.mouse_click("name:EnableAdditional and type:CheckBox") - library.mouse_click("name:ChangeTItle and type:CheckBox") - library.mouse_click("name:ShowExcluding and type:CheckBox") - - -def type_cash(cash_in, on_us_check, not_on_us_check): - library.type_into(CASH_IN, cash_in) - library.type_into(ON_US, on_us_check) - library.type_into(NOT_US, not_on_us_check) - - -def main(): - do_the_application_login() - currentdate = library.get_text(CURRENT_DATE) - currenttime = library.get_text(CURRENT_TIME) - LOGGER.info(f"CURRENT DATE: {currentdate['children_texts']}") - LOGGER.info(f"CURRENT TIME: {currenttime['children_texts']}") - - click_transactions() - click_configurations() - type_cash(500, 300, 100) - click_settings() - - set_slider_value("id:uiScaleSlider and type:Slider", 0.2) - sleep(5) - winlist = library.get_window_list() - for w in winlist: - LOGGER.info(w) - library.refresh_window() - set_slider_value("id:uiScaleSlider and type:Slider", 0.5) - sleep(5) - LOGGER.info("Done.") - - -if __name__ == "__main__": - library = Windows() - try: - main() - finally: - library.close_all_applications() diff --git a/examples/windows-uidemo-application/variables.py b/examples/windows-uidemo-application/variables.py deleted file mode 100644 index 8bae40666c..0000000000 --- a/examples/windows-uidemo-application/variables.py +++ /dev/null @@ -1,6 +0,0 @@ -CURRENT_DATE = "id:DateLabel" -CURRENT_TIME = "id:TimeLabel" - -CASH_IN = "id:cashintb" -ON_US = "id:onustb" -NOT_US = "id:notonustb" \ No newline at end of file diff --git a/invocations/config.py b/invocations/config.py index 2fcc80548e..b13178a76b 100644 --- a/invocations/config.py +++ b/invocations/config.py @@ -407,6 +407,7 @@ def install_local(ctx, package, extra=None, all_extras=False): """ backup_dependency_files(ctx) valid_packages = get_package_paths() + pkg_root = get_current_package_root(ctx) if not package: package = valid_packages.keys() opt_dependencies = [] @@ -417,11 +418,15 @@ def install_local(ctx, package, extra=None, all_extras=False): pkg_name = "main" else: pkg_name = pkg - pkg_root = get_current_package_root(ctx) dependency_path = PACKAGES_ROOT / pkg_name + if dependency_path.resolve() == pkg_root.resolve(): + continue opt_dependencies.append(str(relative_path(pkg_root, dependency_path))) - add_arg = " ".join(opt_dependencies) - shell.uv(ctx, f"add --editable {add_arg}") + if not opt_dependencies: + print("No external local packages to install.") + return + add_arg = " ".join(opt_dependencies) + shell.uv(ctx, f"add --editable {add_arg}", in_stream=False) def relative_path(first_path: Path, second_path: Path) -> Path: diff --git a/invocations/util.py b/invocations/util.py index 0bdf992017..4ed8c71b28 100644 --- a/invocations/util.py +++ b/invocations/util.py @@ -47,8 +47,12 @@ def get_package_paths(): for project_toml in project_tomls: toml_path = Path(project_toml) project_config = toml.load(toml_path) + if "tool" in project_config and "poetry" in project_config.get("tool", {}): + name = str(project_config["tool"]["poetry"]["name"]) + else: + name = str(project_config["project"]["name"]) package_paths[toml_path.parent.name] = { - "name": str(project_config["tool"]["poetry"]["name"]), + "name": name, "path": toml_path.parent.resolve(), } return package_paths diff --git a/packages/core/pyproject.toml b/packages/core/pyproject.toml index 32127f24a5..5cf9dd17ea 100644 --- a/packages/core/pyproject.toml +++ b/packages/core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rpaframework-core" -version = "13.0.1" +version = "13.0.2" description = "Core utilities used by RPA Framework" authors = [{name = "RPA Framework", email = "rpafw@robocorp.com"}] license = {text = "Apache-2.0"} diff --git a/packages/core/src/RPA/core/windows/locators.py b/packages/core/src/RPA/core/windows/locators.py index db55db24c2..06df8c2379 100644 --- a/packages/core/src/RPA/core/windows/locators.py +++ b/packages/core/src/RPA/core/windows/locators.py @@ -188,6 +188,11 @@ class MatchObject: PATH_SEP = "|" # path locator index separator QUOTE = '"' # enclosing quote character (double-quote; single-quote also accepted) _LOCATOR_REGEX = re.compile(r"""\S*"[^"]+"|\S*'[^']+'|\S+""", re.IGNORECASE) + # Matches a stray `locator=` keyword-argument prefix (optionally followed by an + # unmatched quote character) that can end up glued onto the strategy token, e.g. + # when a value like `locator='executable:...` gets swallowed whole by + # `_LOCATOR_REGEX` due to its unclosed quote. + _STRAY_LOCATOR_PREFIX_REGEX = re.compile(r"^locator=['\"]?", re.IGNORECASE) _LOGGER = logging.getLogger(__name__) locators: List[Tuple] = field(default_factory=list) @@ -232,8 +237,19 @@ def handle_locator_part( default_values.append(part_text) return - control_strategy = self._WINDOWS_LOCATOR_STRATEGIES.get(strategy) + stray_match = self._STRAY_LOCATOR_PREFIX_REGEX.match(strategy) + clean_strategy = strategy[stray_match.end() :] if stray_match else strategy + + control_strategy = self._WINDOWS_LOCATOR_STRATEGIES.get(clean_strategy) if control_strategy: + if stray_match: + self._LOGGER.warning( + "Locator part %r looked malformed (stray %r prefix); using" + " strategy %r instead.", + part_text, + stray_match.group(), + clean_strategy, + ) if default_values: add_locator("Name", " ".join(default_values)) default_values.clear() @@ -386,7 +402,18 @@ def _get_control_from_listed_windows( search_params = search_params.copy() # to keep idempotent behaviour win_value = search_params.pop(param_type) window_list = self.ctx.list_windows() - matches = [win for win in window_list if win[win_type] == win_value] + if param_type == "executable": + # Windows file names are case-insensitive, so `executable:notepad.exe` + # has to match a process listed as `Notepad.exe`. Only the executable + # is compared this way; `handle` is numeric and matched as-is. + win_value_folded = str(win_value).casefold() + matches = [ + win + for win in window_list + if str(win[win_type]).casefold() == win_value_folded + ] + else: + matches = [win for win in window_list if win[win_type] == win_value] if not matches: raise WindowControlError( f"Could not locate window with {param_type} {win_value!r}" diff --git a/packages/core/tests/python/test_windows.py b/packages/core/tests/python/test_windows.py index 7d0120432b..e1d9b5d17e 100644 --- a/packages/core/tests/python/test_windows.py +++ b/packages/core/tests/python/test_windows.py @@ -3,7 +3,7 @@ import pytest -from RPA.core.windows.context import ElementNotFound +from RPA.core.windows.context import ElementNotFound, WindowControlError from RPA.core.windows.locators import LocatorMethods, MatchObject @@ -96,6 +96,14 @@ class TestMatchObject: "Calculator > path:2|3|2|8|2", [("Name", "Calculator", 0), ("path", [2, 3, 2, 8, 2], 1)], ), + ( + "locator='executable:AsdfConfigurator.exe", + [("executable", "AsdfConfigurator.exe", 0)], + ), # stray `locator=` keyword-arg prefix + unmatched quote (issue #1323) + ( + "LOCATOR=\"executable:AsdfConfigurator.exe", + [("executable", "AsdfConfigurator.exe", 0)], + ), # case-insensitive, double-quote variant ], ) def test_match_object(self, locator, locators): @@ -126,3 +134,54 @@ def test_get_control_from_path(self, library, search_params, should_raise): with should_raise: leaf = library._get_control_from_path(search_params, root_control) assert leaf == child22 + + @pytest.mark.parametrize( + "locator_value, listed_name, should_match", + [ + # Windows file names are case-insensitive, so the case a user writes + # must not decide whether the window is found. Windows 11 lists + # Notepad as "Notepad.exe" while everyone writes "notepad.exe". + ("notepad.exe", "Notepad.exe", True), + ("Notepad.exe", "notepad.exe", True), + ("NOTEPAD.EXE", "Notepad.exe", True), + ("notepad.exe", "notepad.exe", True), + # Different executables must still not match each other. + ("notepad.exe", "wordpad.exe", False), + ], + ) + def test_executable_match_is_case_insensitive( + self, library, locator_value, listed_name, should_match + ): + library.ctx.list_windows.return_value = [ + {"name": listed_name, "title": "Untitled - Notepad", "handle": 1} + ] + + should_raise = ( + nullcontext() if should_match else pytest.raises(WindowControlError) + ) + with should_raise, mock.patch.object( + library, "_get_control_from_params" + ) as get_control: + library._get_control_from_listed_windows( + {"executable": locator_value}, param_type="executable", win_type="name" + ) + # The window title of the matched process is what gets searched for. + assert get_control.call_args[0][0]["Name"] == "Untitled - Notepad" + + def test_handle_match_stays_exact(self, library): + """Only `executable` is folded - `handle` is numeric and must not be.""" + library.ctx.list_windows.return_value = [ + {"name": "notepad.exe", "title": "Untitled - Notepad", "handle": 12345} + ] + + with mock.patch.object(library, "_get_control_from_params") as get_control: + library._get_control_from_listed_windows( + {"handle": 12345}, param_type="handle", win_type="handle" + ) + assert get_control.call_args[0][0]["Name"] == "Untitled - Notepad" + + with pytest.raises(WindowControlError): + library._get_control_from_listed_windows( + {"handle": 99999}, param_type="handle", win_type="handle" + ) + diff --git a/packages/core/uv.lock b/packages/core/uv.lock index 480fb1fdff..890f46285a 100644 --- a/packages/core/uv.lock +++ b/packages/core/uv.lock @@ -675,7 +675,7 @@ wheels = [ [[package]] name = "rpaframework-core" -version = "13.0.1" +version = "13.0.2" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/main/pyproject.toml b/packages/main/pyproject.toml index e2e97feafa..1640605066 100644 --- a/packages/main/pyproject.toml +++ b/packages/main/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rpaframework" -version = "32.0.2" +version = "33.0.0" description = "A collection of tools and libraries for RPA" authors = [{name = "RPA Framework", email = "rpafw@robocorp.com"}] license = {text = "Apache-2.0"} diff --git a/packages/main/src/RPA/Desktop/Windows.py b/packages/main/src/RPA/Desktop/Windows.py deleted file mode 100644 index 4641bdf375..0000000000 --- a/packages/main/src/RPA/Desktop/Windows.py +++ /dev/null @@ -1,2071 +0,0 @@ -# pylint: disable=c-extension-no-member -# pylint: disable=too-many-lines -# pylint: disable=possibly-used-before-assignment -# pylint: disable=consider-using-dict-items -# pylint: disable=used-before-assignment -# pylint: disable=consider-using-min-builtin -# pylint: disable=consider-using-max-builtin -from enum import Enum -import json -import logging -import os -import platform -import re -import subprocess -import time -from pathlib import Path -from typing import Any, Optional - -from RPA.Desktop import Desktop -from RPA.Desktop.Clipboard import Clipboard -from RPA.Desktop.OperatingSystem import OperatingSystem -from RPA.core.geometry import Region -from RPA.core.helpers import delay, clean_filename -from RPA.core.locators import ImageLocator -from RPA.core.logger import deprecation - - -if platform.system() == "Windows": - import ctypes - import win32api - import win32com.client - import win32con - import win32security - import pywinauto - import win32gui - from comtypes import COMError -else: - logging.getLogger(__name__).warning( - "RPA.Desktop.Windows library works only on Windows platform" - ) - - -def write_element_info_as_json( - elements: Any, filename: str, path: str = "output/json" -) -> None: - """Write list of elements into json file - - :param elements: list of elements to write - :param filename: output file name - :param path: output directory, defaults to "output/json" - """ - elements = elements if isinstance(elements, list) else [elements] - filename = Path(f"{path}/{filename}.json") - os.makedirs(filename.parent, exist_ok=True) - with open(filename, "w", encoding="utf-8") as outfile: - json.dump(elements, outfile, indent=4, sort_keys=True) - - -class ElementNotFoundError(Exception): - """Raised when expected element is not found""" - - -class MenuItemNotFoundError(Exception): - """Raised when expected menu item is not found""" - - -class UnknownWindowsBackendError(Exception): - """Raised when unknown Windows backend is set""" - - -class ApplicationNotStarted(Exception): - """Raised when application fails to start""" - - -SUPPORTED_BACKENDS = ["uia", "win32"] -WINDOWS_LOCATOR_STRATEGIES = { - "name": "name", - "class_name": "class_name", - "class": "class_name", - "control_type": "control_type", - "type": "control_type", - "automation_id": "automation_id", - "id": "automation_id", - "partial name": "partial name", - "regexp": "regexp", - "parent": "parent", -} - - -class Speed(Enum): - """Values for pywinauto Timings""" - - DEFAULT = 1 - SLOW = 2 - FAST = 3 - - -def to_speed(value): - """Convert value to Speed enum.""" - if isinstance(value, Speed): - return value - - sanitized = str(value).lower().strip().replace(" ", "_") - try: - return Speed[sanitized] - except KeyError as err: - raise ValueError(f"Unknown speed: {value}") from err - - -class Windows(OperatingSystem): - """`Windows` is a library for managing the Windows operating system. - - **DEPRECATION WARNING! USE RPA.Windows library instead.** - - For Windows desktop automation Robocorp recommends the ``RPA.Windows`` library. - - No further updates will be released for this library and new functionality will continue - to be developed in ``RPA.Windows`` library. - - **Running Windows applications** - - Windows applications can be started in several ways. The library supports - the following keywords: - - - Open Application (dispatch Office applications) - - Open File (open the file as process which opens the associated application) - - Open Executable (uses pywinauto start) - - Open Using Run Dialog (uses Windows run dialog) - - Open From Search (uses Windows search dialog) - - **Locators** - - `Locator` is used to identify the element for interaction - usually for a mouse click. - - Locators can investigated for application once it has been opened by calling - the keyword `get_windows_elements` which can store locator information into JSON file - and `screenshot` of the element into an image file. - - **Identifying locator** - - The element needs to be identified by a unique method, for example, "Three" for button 3 - in the Calculator application. It can be given either as `Three` or `name:Three`. - - Possible search criterias: - - - name - - class (class_name) - - type (control_type) - - id (automation_id) - - any if none was defined - - The current method of inspecting elements on Windows is `inspect.exe` which is part - of `Windows SDK `_. - - **Keyboard** - - The keyword `send_keys` can be used to send keys to the active window. The keyword - `type_keys` sends keys to the active window element. - - Special key codes are documented on `pywinauto `_ - documentation page. - - **FAQ** - - Q. I see error message `AttributeError: module 'win32com.gen_py.00020813-0000-0000-C000-000000000046x0x1x9' has no attribute 'CLSIDToClassMap'` - - A. From PowerShell run this command: `Remove-Item -path $env:LOCALAPPDATA\\Temp\\gen_py -recurse` - - **Examples** - - **Robot Framework** - - .. code-block:: robotframework - - *** Settings *** - Library RPA.Desktop.Windows - Suite Teardown Close all applications - - *** Tasks *** - Open Calculator using run dialog - ${result}= Open using run dialog calc.exe Calculator - ${result}= Get Window Elements - Send Keys 5*2= - ${result}= Get element partial name:Display is - Log Many ${result} - ${result}= Get element rich text id:CalculatorResults - Should Be Equal As Strings ${result} Display is 10 - ${result}= Get element rectangle partial name:Display is - ${result}= Is Element Visible CalculatorResults - ${result}= Is Element Enabled partial name:Display is - - **Python** - - .. code-block:: python - - from RPA.Desktop.Windows import Windows - - win = Windows() - - def open_calculator(): - win.open_from_search("calc.exe", "Calculator") - elements = win.get_window_elements() - - def make_calculations(expression): - win.send_keys(expression) - result = win.get_element_rich_text('id:CalculatorResults') - return int(result.strip('Display is ')) - - if __name__ == "__main__": - open_calculator() - exp = '5*2=' - result = make_calculations(exp) - print(f"Calculation result of '{exp}' is '{result}'") - win.close_all_applications() - """ # noqa: E501 - - ROBOT_LIBRARY_SCOPE = "GLOBAL" - ROBOT_LIBRARY_DOC_FORMAT = "REST" - - def __init__(self, backend: str = "uia") -> None: - deprecation( - "`RPA.Desktop.Windows` got deprecated and will be no longer maintained, " - "please use `RPA.Windows` instead " - "(https://robocorp.com/docs-robot-framework/libraries/rpa-framework/rpa-windows)" - ) - - OperatingSystem.__init__(self) - self._apps = {} - self._app_instance_id = 0 - self._active_app_instance = -1 - self.set_windows_backend(backend) - self.app = None - self.dlg = None - self.windowtitle = None - self.logger = logging.getLogger(__name__) - self.clipboard = Clipboard() - self.elements = None - self.controls = None - - def set_windows_backend(self, backend: str) -> None: - """Set Windows backend which is used to interact with Windows - applications - - Allowed values defined by `SUPPORTED_BACKENDS` - - :param backend: name of the backend to use - - Example: - - .. code-block:: robotframework - - Set Windows Backend uia - Open Executable calc.exe Calculator - Set Windows Backend win32 - Open Executable calc.exe Calculator - - """ - if backend and backend.lower() in SUPPORTED_BACKENDS: - self._backend = backend.lower() - else: - raise UnknownWindowsBackendError( - "Unsupported Windows backend: %s" % backend - ) - - def _add_app_instance( - self, - app: Any = None, - dialog: bool = True, - params: dict = None, - ) -> int: - params = params or {} - self._app_instance_id += 1 - process_id = None - handle = None - if app: - self.app = app - if hasattr(app, "process"): - process_id = app.process - handle = win32gui.GetForegroundWindow() - - default_params = { - "app": app, - "id": self._app_instance_id, - "dialog": dialog, - "process_id": process_id, - "handle": handle, - "dispatched": False, - } - - self._apps[self._app_instance_id] = {**default_params, **params} - - self.logger.debug( - "Added app instance %s: %s", - self._app_instance_id, - self._apps[self._app_instance_id], - ) - self._active_app_instance = self._app_instance_id - return self._active_app_instance - - def switch_to_application(self, app_id: int) -> None: - """Switch to application by id. - - :param app_id: application's id - :raises ValueError: if application is not found by given id - - Example: - - .. code-block:: robotframework - - ${app1} Open Application Excel - ${app2} Open Application Word - Switch To Application ${app1} - - """ - if app_id and app_id in self._apps.keys(): - app = self.get_app(app_id) - self._active_app_instance = app_id - self.app = app["app"] - if "windowtitle" in app: - self.open_dialog(app["windowtitle"], existing_app=True) - delay(0.5) - self.restore_dialog(app["windowtitle"]) - else: - raise ValueError(f"No open application with id '{app_id}'") - - def get_open_applications(self): - """Get list of all open applications - - Returns a dictionary - - Example: - - .. code-block:: robotframework - - ${app1} Open Application Excel - ${app2} Open Executable calc.exe Calculator - ${app3} Open File /path/to/myfile.txt - &{apps} Get Open Applications - - """ - return self._apps - - def get_app(self, app_id: int = None) -> Any: - """Get application object by id - - By default returns active_application application object. - - :param app_id: id of the application to get, defaults to None - :return: application object - - Example: - - .. code-block:: robotframework - - ${app1} Open Application Excel - &{appdetails} Get App ${app1} - - """ - if app_id is None and self._active_app_instance != -1: - return self._apps[self._active_app_instance] - else: - return self._apps[app_id] - - def open_application(self, application: str) -> int: - """Open application by dispatch method - - This keyword is used to launch Microsoft applications like - Excel, Word, Outlook and Powerpoint. - - :param application: name of the application as `str` - :return: application instance id - - Example: - - .. code-block:: robotframework - - ${app1} Open Application Excel - ${app2} Open Application Word - - """ - self.logger.info("Open application: %s", application) - app = win32com.client.gencache.EnsureDispatch(f"{application}.Application") - app.Visible = True - # show eg. file overwrite warning or not - if hasattr(self.app, "DisplayAlerts"): - app.DisplayAlerts = False - params = { - "dispatched": True, - "startkeyword": "Open Application", - } - return self._add_app_instance(app, dialog=False, params=params) - - def open_file( - self, - filename: str, - windowtitle: str = None, - wildcard: bool = False, - timeout: int = 10, - ) -> Optional[int]: - """Open associated application when opening file - - Keyword `Open Dialog` is used if `windowtitle` is given. - - :param filename: path to file - :param windowtitle: name of the window - :param wildcard: set True for inclusive window title search, default False - :param timeout: time to wait for dialog to appear - :return: application id or None - - Example: - - .. code-block:: robotframework - - ${app1} Open File /path/to/myfile.txt - - """ - self.logger.info("Open file: %s", filename) - if platform.system() == "Windows": - # pylint: disable=no-member - os.startfile(filename) - elif platform.system() == "Darwin": - subprocess.call(["open", filename]) - else: - subprocess.call(["xdg-open", filename]) - - app_instance = None - if windowtitle: - app_instance = self.open_dialog( - windowtitle, wildcard=wildcard, timeout=timeout - ) - if app_instance > 0: - self._apps[app_instance]["executable"] = filename - self._apps[app_instance]["startkeyword"] = "Open File" - - return app_instance - - def open_executable( - self, - executable: str, - windowtitle: str, - backend: str = None, - work_dir: str = None, - wildcard: bool = False, - ) -> int: - """Open Windows executable. Window title name is required - to get handle on the application. - - :param executable: name of the executable - :param windowtitle: name of the window - :param backend: set Windows backend, default None means using - library default value - :param work_dir: path to working directory, default None - :param wildcard: set True for inclusive window title search, default False - :return: application instance id - - Example: - - .. code-block:: robotframework - - ${app1} Open Executable calc.exe Calculator - ${app2} Open Executable notepad.exe Notepad wildcard=True - - """ - self.logger.info("Opening executable: %s - window: %s", executable, windowtitle) - if backend: - self.set_windows_backend(backend) - - app = pywinauto.Application(backend=self._backend).start( - cmd_line=executable, work_dir=work_dir - ) - app_instance = self.open_dialog(windowtitle, wildcard=wildcard) - self._apps[app_instance]["app"] = app - self._apps[app_instance]["executable"] = executable - self._apps[app_instance]["startkeyword"] = "Open Executable" - return app_instance - - def open_using_run_dialog( - self, - executable: str, - windowtitle: str, - timeout: int = 10, - wildcard: bool = False, - ) -> int: - """Open application using Windows run dialog. - Window title name is required to get handle on the application. - - :param executable: name of the executable - :param windowtitle: name of the window - :param timeout: time to wait for dialog to appear - :param wildcard: set True for inclusive window title search, default False - :return: application instance id - - Example: - - .. code-block:: robotframework - - ${app1} Open Using Run Dialog notepad Untitled - Notepad - ${app2} Open Using Run Dialog notepad Notepad wildcard=True - - """ - self.send_keys("{VK_LWIN down}r{VK_LWIN up}") - delay(1) - - self.send_keys_to_input(executable, send_delay=0.2, enter_delay=0.5) - - app_instance = self.open_dialog(windowtitle, timeout=timeout, wildcard=wildcard) - self._apps[app_instance]["executable"] = executable - self._apps[app_instance]["startkeyword"] = "Open Using Run Dialog" - return app_instance - - def open_from_search( - self, - executable: str, - windowtitle: str, - timeout: int = 10, - wildcard: bool = False, - ) -> int: - """Open application using Windows search dialog. - Window title name is required to get handle on the application. - - :param executable: name of the executable - :param windowtitle: name of the window - :param timeout: time to wait for dialog to appear - :param wildcard: set True for inclusive window title search, default False - :return: application instance id - - Example: - - .. code-block:: robotframework - - ${app1} Open From Search calculator Calculator - ${app2} Open From Search notepad Notepad wildcard=True - - """ - self.logger.info("Run from start menu: %s", executable) - self.send_keys("{LWIN}") - delay(1) - - self.send_keys_to_input(executable) - - app_instance = None - for _ in range(10): - try: - app_instance = self.open_dialog( - windowtitle, timeout=timeout, wildcard=wildcard - ) - except AttributeError: - pass - except COMError: - pass - if app_instance: - self._apps[app_instance]["executable"] = executable - self._apps[app_instance]["startkeyword"] = "Open From Search" - break - else: - time.sleep(0.5) - if not app_instance: - raise ApplicationNotStarted("Unable to get application instance") - return app_instance - - def get_spaced_string(self, text): - """Replace spaces in a text with `pywinauto.keyboard` - space characters `{VK_SPACE}` - - :param text: replace spaces in this string - - Example: - - .. code-block:: robotframework - - ${txt} Get Spaced String My name is Bond - # ${txt} = My{VK_SPACE}name{VK_SPACE}is{VK_SPACE}Bond - Send Keys To Input ${txt} - - """ - return text.replace(" ", "{VK_SPACE}") - - def send_keys_to_input( - self, - keys_to_type: str, - with_enter: bool = True, - send_delay: float = 0.5, - enter_delay: float = 1.5, - ) -> None: - """Send keys to windows and add ENTER if `with_enter` is True - - At the end of send_keys there is by default 0.5 second delay. - At the end of ENTER there is by default 1.5 second delay. - - :param keys_to_type: keys to type into Windows - :param with_enter: send ENTER if `with_enter` is True - :param send_delay: delay after send_keys - :param enter_delay: delay after ENTER - - Example: - - .. code-block:: robotframework - - ${txt} Get Spaced String My name is Bond, James Bond - Send Keys To Input ${txt} with_enter=False - Send Keys To Input {ENTER}THE send_delay=5.0 with_enter=False - Send Keys To Input {VK_SPACE}-{VK_SPACE}END enter_delay=5.0 - - """ - # Set keyboard layout for Windows platform - if platform.system() == "Windows": - win32api.LoadKeyboardLayout("00000409", 1) - - self.send_keys(keys_to_type) - delay(send_delay) - if with_enter: - self.send_keys("{ENTER}") - delay(enter_delay) - - def minimize_dialog(self, windowtitle: str = None) -> None: - """Minimize window by its title - - :param windowtitle: name of the window, default `None` means that - active window is going to be minimized - - Example: - - .. code-block:: robotframework - - Open Using Run Dialog calc Calculator - Open Using Run Dialog notepad Untitled - Notepad - Minimize Dialog # Current window (Notepad) - Minimize Dialog Calculator - - """ - windowtitle = ( - windowtitle or self._apps[self._active_app_instance]["windowtitle"] - ) - self.logger.info("Minimize dialog: %s", windowtitle) - self.dlg = pywinauto.Desktop(backend=self._backend)[windowtitle] - self.dlg.minimize() - - def restore_dialog(self, windowtitle: str = None) -> None: # noqa: C901 - """Restore window by its title - - :param windowtitle: name of the window, default `None` means that - active window is going to be restored - - Example: - - .. code-block:: robotframework - - Open Using Run Dialog notepad Untitled - Notepad - Minimize Dialog - Sleep 1s - Restore Dialog - Sleep 1s - Restore Dialog Untitled - Notepad - - """ - # TODO. Handle too compled method - self.logger.info("Restore dialog: %s", windowtitle) - if windowtitle is None and self._active_app_instance == -1: - raise ValueError( - "There are no applications opened by library or window title is empty" - ) - app = None - handle = None - if windowtitle is None: - app = self._apps[self._active_app_instance]["app"] - handle = self._apps[self._active_app_instance]["handle"] - windowtitle = self._apps[self._active_app_instance]["windowtitle"] - else: - for app_id, app in self._apps.items(): - if "windowtitle" in app.keys() and windowtitle == app["windowtitle"]: - application_id = app_id - app = self._apps[application_id]["app"] - handle = self._apps[application_id]["handle"] - try: - if app and handle > 0: - app.window(handle=handle).restore() - else: - wins = self.get_window_list() - for win in wins: - if windowtitle == win["title"]: - handle = win["handle"] - break - if handle and handle > 0: - self.connect_by_handle(handle, windowtitle=windowtitle) - self.restore_dialog() - else: - raise ValueError("Could not restore dialog: %s" % windowtitle) - except pywinauto.findwindows.ElementAmbiguousError as e: - self.logger.info("Could not restore dialog, %s", str(e)) - - def open_dialog( - self, - windowtitle: str = None, - highlight: bool = False, - timeout: int = 10, - existing_app: bool = False, - wildcard: bool = False, - parse_elements: bool = True, - ) -> Any: - """Open window by its title. - - :param windowtitle: name of the window, defaults to active window if None - :param highlight: draw outline for window if True, default False - :param timeout: time to wait for dialog to appear - :param existing_app: set True if selecting window which library has already - accessed, default False - :param wildcard: set True for inclusive window title search, default False - :param parse_elements: set False to not to parse elements of the window, - default True - - Example: - - .. code-block:: robotframework - - Open Dialog Untitled - Notepad - Open Dialog Untitled - Notepad highlight=True timeout=5 - Open Dialog Notepad wildcard=True - - """ - self.logger.info("Open dialog: '%s'", windowtitle) - - app_instance = None - end_time = time.time() + float(timeout) - while time.time() < end_time and app_instance is None: - for window in self.get_window_list(): - if (not wildcard and windowtitle == window["title"]) or ( - wildcard and windowtitle in window["title"] - ): - self.windowtitle = window["title"] - app_instance = self.connect_by_handle( - window["handle"], - windowtitle=self.windowtitle, - existing_app=existing_app, - parse_elements=parse_elements, - ) - break - time.sleep(0.1) - - if app_instance is None: - raise ValueError( - "No window with title '%s', wildcard: %s" % (windowtitle, wildcard) - ) - - if highlight: - self.dlg.draw_outline() - - return app_instance - - def connect_by_pid(self, app_pid: str, windowtitle: str = None) -> Any: - """Connect to application by its pid - - :param app_pid: process id of the application - :param windowtitle: name of the window, defaults to active window if None - - Example: - - .. code-block:: robotframework - - ${appid} Connect By PID 3231 - - """ - self.logger.info("Connect to application pid: %s", app_pid) - window_list = self.get_window_list() - for win in window_list: - if win["pid"] == app_pid: - if windowtitle is None or (windowtitle and windowtitle in win["title"]): - self.logger.info( - "PID:%s matched window title:%s", win["pid"], win["title"] - ) - return self.connect_by_handle(win["handle"], windowtitle) - return None - - def connect_by_handle( - self, - handle: int, - windowtitle: str = None, - existing_app: bool = False, - parse_elements: bool = True, - ) -> Any: - """Connect to application by its handle - - :param handle: handle of the application - :param windowtitle: name of the window, defaults to active window if None - :param existing_app: set True if selecting window which library has already - accessed, default False - :param parse_elements: set False to not to parse elements of the window, - default True - - Example: - - .. code-block:: robotframework - - ${appid} Connect By Handle 88112 - - """ - self.logger.info("Connect to application handle: %s", handle) - app_instance = None - app = pywinauto.Application(backend=self._backend).connect( - handle=handle, visible_only=False - ) - self.dlg = app.window(handle=handle) - self.dlg.set_focus() - params = None - if existing_app: - for key in self._apps: - if self._apps[key]["handle"] == handle: - app_instance = key - break - else: - if windowtitle is not None: - params = {"windowtitle": windowtitle} - app_instance = self._add_app_instance(app=app, params=params, dialog=False) - if parse_elements: - self.refresh_window() - return app_instance - - def close_all_applications(self) -> None: - """Close all applications - - Example: - - .. code-block:: robotframework - - Open Application Excel - Open Application Word - Open Executable notepad.exe Untitled - Notepad - Close All Applications - - """ - self.logger.info("Closing all applications") - self.logger.debug("Applications in memory: %d", len(self._apps)) - for aid in list(self._apps): - self.quit_application(aid) - del self._apps[aid] - - def quit_application(self, app_id: int = None, send_keys: bool = False) -> None: - """Quit an application by application id or - active application if `app_id` is None. - - :param app_id: application_id, defaults to None - :param send_keys: if ALT+F4 should be used to quit, default False - - Example: - - .. code-block:: robotframework - - ${app1} Open Application Excel - ${app2} Open Application Word - Quit Application ${app1} - - """ - app = self.get_app(app_id) - self.logger.info("Quit application: %s (%s)", app_id, app) - if send_keys: - self.logger.info("Quit by F4 shortcut") - self.switch_to_application(app_id) - self.send_keys("%{F4}") - else: - if app["dispatched"]: - self.logger.info("Quit by app.Quit()") - app["app"].Quit() - else: - if "process_id" in app and app["process_id"] > 0: - # pylint: disable=E1101 - pid = app["process_id"] - if self.process_id_exists(pid): - self.logger.info("Quit by killing process id") - self.kill_process_by_pid(pid) - else: - self.logger.info("Process pid '%s' did not exist anymore", pid) - else: - self.logger.info("Quit by app.kill()") - app["app"].kill() - self._active_app_instance = -1 - - def type_keys(self, keys: str) -> None: - """Type keys into active window element. - - :param keys: list of keys to type - - Example: - - .. code-block:: robotframework - - Open Executable notepad.exe Untitled - Notepad - Type Keys My text - - """ - if self.dlg is None: - raise ValueError("No dialog open") - self.dlg.type_keys(keys) - - def type_into(self, locator: str, keys: str, empty_field: bool = False) -> None: - """Type keys into element matched by given locator. - - :param locator: element locator - :param keys: list of keys to type - :param empty_field: if field should be emptied before typing, default False - - Example: - - .. code-block:: robotframework - - Open Executable calc.exe Calculator - Type Into CalculatorResults 11 - Type Into CalculatorResults 22 empty_field=True - - """ - elements, _ = self.find_element(locator) - if elements and len(elements) == 1: - ctrl = elements[0]["control"] - empty_method = "^a{BACKSPACE}" if empty_field else "" - ctrl.type_keys(f"{empty_method}{keys}") - else: - raise ValueError(f"Could not find unique element for '{locator}'") - - def send_keys(self, keys: str) -> None: - """Send keys into active windows. - - :param keys: list of keys to send - - Example: - - .. code-block:: robotframework - - Open Executable calc.exe Calculator - Send Keys 2{+}3{=} - - """ - pywinauto.keyboard.send_keys(keys) - - def get_text(self, locator: str) -> dict: - """Get text from element - - :param locator: element locator - - Example: - - .. code-block:: robotframework - - Open Using Run Dialog calc Calculator - Type Into CalculatorResults 11 - Type Into CalculatorResults 55 - &{val} Get Text CalculatorResults - - """ - elements, _ = self.find_element(locator) - element_text = {} - if elements and len(elements) == 1: - ctrl = elements[0]["control"] - element_text["value"] = ( - str(ctrl.get_value()) if hasattr(ctrl, "get_value") else None - ) - element_text["children_texts"] = ( - "".join(ctrl.children_texts()) - if hasattr(ctrl, "children_texts") - else None - ) - element_text["rich_text"] = ( - elements[0]["rich_text"] if "rich_text" in elements[0].keys() else "" - ) - legacy = ( - ctrl.legacy_properties() if hasattr(ctrl, "legacy_properties") else None - ) - element_text["legacy_value"] = str(legacy["Value"]) if legacy else None - element_text["legacy_name"] = str(legacy["Name"]) if legacy else None - return element_text - - def mouse_click( - self, - locator: str = None, - x: int = 0, - y: int = 0, - off_x: int = 0, - off_y: int = 0, - image: str = None, - method: str = "locator", - ctype: str = "click", - focus: str = "center", - tolerance: Optional[int] = None, - ) -> None: - # pylint: disable=C0301 - """Mouse click `locator`, `coordinates`, or `image` - - When using method `locator`,`image` or `ocr` mouse is clicked by default at - center coordinates. - - Click types are: - - - `click` normal left button mouse click - - `double` - - `right` - - :param locator: element locator on active window - :param x: coordinate x on desktop - :param y: coordinate y on desktop - :param off_x: offset x (used for locator and image clicks) - :param off_y: offset y (used for locator and image clicks) - :param image: image to click on desktop - :param method: one of the available methods to mouse click, default "locator" - :param ctype: type of mouse click - :param focus: default point for element click is 'center', can be set to 'topleft' - to click top left corner of the element - :param tolerance: image matching tolerance between 0 and 1 - - Example: - - .. code-block:: robotframework - - Mouse Click method=coordinates 100 100 - Mouse Click CalculatorResults - Mouse Click method=image image=myimage.png off_x=10 off_y=10 ctype=right - Mouse Click method=image image=myimage.png tolerance=0.8 - ${elements} ${other}= Find Element class:Button - FOR ${element} IN @{elements} - Run Keyword If ${element}[visible] Mouse Click ${element} - END - Mouse Click id:TrickyCheckbox focus=topleft - - """ # noqa: E501 - self.logger.info("Mouse click: %s", locator) - - if method == "locator": - target_element = None - if isinstance(locator, dict): - target_element = locator - else: - element, _ = self.find_element(locator) - if element and len(element) == 1: - target_element = element[0] - else: - raise ValueError(f"Could not find unique element for '{locator}'") - if target_element is None: - raise ValueError("Could not find unique element to click") - if focus == "topleft": - x, y, _, _ = self._get_element_coordinates(target_element) - x += 2 - y += 2 - else: - x, y = self.get_element_center(target_element) - self.click_type(x + off_x, y + off_y, ctype) - elif method == "coordinates": - self.mouse_click_coords(x, y, ctype) - elif method == "image": - self.mouse_click_image(image, off_x, off_y, ctype, tolerance) - - def mouse_click_image( - self, - template: str, - off_x: int = 0, - off_y: int = 0, - ctype: str = "click", - tolerance: Optional[float] = None, - ) -> None: - """Click at template image on desktop - - :param image: image to click on desktop - :param off_x: horizontal offset from top left corner to click on - :param off_y: vertical offset from top left corner to click on - :param ctype: type of mouse click - :param tolerance: matching tolerance between 0 and 1 - - Example: - - .. code-block:: robotframework - - Mouse Click image=myimage.png off_x=10 off_y=10 ctype=right - Mouse Click image=myimage.png tolerance=0.8 - - """ - confidence = tolerance * 100.0 if tolerance is not None else None - locator = ImageLocator(template, confidence=confidence) - - match = Desktop().find_element(locator) - - target = match.center.move(off_x, off_y) - self.click_type(target.x, target.y, ctype) - - def mouse_click_coords( - self, x: int, y: int, ctype: str = "click", delay_time: float = None - ) -> None: - """Click at coordinates on desktop - - :param x: horizontal coordinate on the windows to click - :param y: vertical coordinate on the windows to click - :param ctype: click type "click", "right" or "double", defaults to "click" - :param delay: delay in seconds after, default is no delay - - Example: - - .. code-block:: robotframework - - Mouse Click Coords x=450 y=100 - Mouse Click Coords x=300 y=300 ctype=right - Mouse Click Coords x=450 y=100 delay=5.0 - - """ - self.click_type(x, y, ctype) - if delay_time: - delay(delay_time) - - def get_element( - self, locator: str, screenshot: bool = False, open_dialog: bool = True - ) -> Any: - """Get element by locator. - - :param locator: name of the locator - :param screenshot: takes element screenshot if True, defaults to False - :param open_dialog: True if dialog should be reopened, default to True - :return: element if element was identified, else False - - Example: - - .. code-block:: robotframework - - ${element} Get Element CalculatorResults - ${element} Get Element Result screenshot=True - - """ - self.logger.info("Get element: %s", locator) - if open_dialog: - self.open_dialog(self.windowtitle) - self.dlg.wait("exists enabled visible ready") - - matching_elements, _ = self.find_element(locator) - - if len(matching_elements) == 0: - self.logger.info( - "Locator '%s' not found in '%s'.\n", - locator, - self.windowtitle, - ) - elif len(matching_elements) == 1: - element = matching_elements[0] - if screenshot: - self.screenshot(f"locator_{locator}", element=element) - for key in element.keys(): - self.logger.debug("%s=%s", key, element[key]) - return element - else: - # TODO: return more valuable information about what should - # be matching element ? - self.logger.info( - "Locator '%s' matched multiple elements in '%s'. ", - locator, - self.windowtitle, - ) - return False - - def get_element_rich_text(self, locator: str) -> Any: - """Get value of element `rich text` attribute. - - :param locator: element locator - :return: `rich_text` value if found, else False - - Example: - - .. code-block:: robotframework - - ${text} Get Element Rich Text CalculatorResults - - """ - element = self.get_element(locator) - if element is not False and "rich_text" in element: - return element["rich_text"] - elif element is False: - self.logger.info("Did not find element with locator: %s", locator) - return False - else: - self.logger.info( - "Element for locator %s does not have 'rich_text' attribute", locator - ) - return False - - def get_element_rectangle(self, locator: str, as_dict: bool = False) -> Any: - # pylint: disable=C0301 - """Get value of element `rectangle` attribute. - - :param locator: element locator - :param as_dict: return values in a dictionary, default `False` - :return: (left, top, right, bottom) values if found, else False - - Example: - - .. code-block:: robotframework - - ${left} ${top} ${right} ${bottom}= Get Element Rectangle CalculatorResults - &{coords} Get Element Rectangle CalculatorResults as_dict=True - Log top=${coords.top} left=${coords.left} - - """ # noqa: E501 - rectangle = self._get_element_attribute(locator, "rectangle") - left, top, right, bottom = self._get_element_coordinates(rectangle) - if as_dict: - return {"left": left, "top": top, "right": right, "bottom": bottom} - return left, top, right, bottom - - def _get_element_attribute(self, locator: str, attribute: str) -> Any: - element = self.get_element(locator) - if element is not False and attribute in element: - return element[attribute] - elif element is False: - self.logger.info("Did not find element with locator %s", locator) - return False - else: - self.logger.info( - "Element for locator %s does not have 'visible' attribute", locator - ) - return False - - def is_element_visible(self, locator: str) -> bool: - """Is element visible. - - :param locator: element locator - :return: True if visible, else False - - Example: - - .. code-block:: robotframework - - ${res}= Is Element Visible CalculatorResults - - """ - visible = self._get_element_attribute(locator, "visible") - return bool(visible) - - def is_element_enabled(self, locator: str) -> bool: - """Is element enabled. - - :param locator: element locator - :return: True if enabled, else False - - Example: - - .. code-block:: robotframework - - ${res}= Is Element Enabled CalculatorResults - - """ - enabled = self._get_element_attribute(locator, "enabled") - return bool(enabled) - - def menu_select(self, menuitem: str) -> None: - """Select item from menu - - :param menuitem: name of the menu item - - Example: - - .. code-block:: robotframework - - Open Using Run Dialog notepad Untitled - Notepad - Menu Select File->Print - - """ - self.logger.info("Menu select: %s", menuitem) - if self.dlg is None: - raise ValueError("No dialog open") - try: - self.dlg.menu_select(menuitem) - except AttributeError as e: - raise MenuItemNotFoundError( - "Unable to access menu item '%s'" % menuitem - ) from e - - def wait_for_element( - self, - locator: str, - use_refreshing: bool = False, - search_criteria: str = None, - timeout: float = 30.0, - interval: float = 2.0, - ) -> Any: - """Wait for element to appear into the window. - - Can return 1 or more elements matching locator, or raises - `ElementNotFoundError` if element is not found within timeout. - - :param locator: name of the locator - :param use_refreshing: wait for element(s) which are not there yet e.g. listbox - item or popups, default False - :param search_criteria: criteria by which element is matched - :param timeout: defines how long to wait for element to appear, - defaults to 30.0 seconds - :param interval: how often to poll for element, - defaults to 2.0 seconds (minimum is 0.5 seconds) - - Example: - - .. code-block:: robotframework - - @{elements} Wait For Element CalculatorResults - @{elements} Wait For Element Results timeout=10 interval=1.5 - - """ - self.refresh_window() - end_time = time.time() + float(timeout) - interval = max([0.5, interval]) - elements = None - while time.time() < end_time: - elements, _ = self.find_element(locator, search_criteria) - if use_refreshing: - self.refresh_window() - if len(elements) > 0: - break - if interval >= timeout: - self.logger.info( - "Wait For Element: interval has been set longer than timeout - " - "executing one cycle." - ) - break - if time.time() >= end_time: - break - time.sleep(interval) - if elements: - return elements - raise ElementNotFoundError - - def find_element(self, locator: str, search_criteria: str = None) -> Any: - """Find element from window by locator and criteria. - - :param locator: name of the locator - :param search_criteria: criteria by which element is matched - :return: list of matching elements and locators that were found on the window - - Example: - - .. code-block:: robotframework - - @{elements} Find Element CalculatorResults - Log Many ${elements[0]} # list of matching elements - Log Many ${elements[1]} # list of all available locators - - """ - match_type, search_locators = self._parse_locator(locator, search_criteria) - if self.elements is None: - controls, elements = self.get_window_elements() - else: - controls = self.controls - elements = self.elements - - matching_elements, locators = [], [] - - for ctrl, element in zip(controls, elements): - match_results = {} - for criteria, search_locator in search_locators: - match_results[criteria] = self.is_element_matching( - element, search_locator, criteria - ) - if (match_type == "all" and all(match_results.values())) or ( - match_type == "any" and any(match_results.values()) - ): - element["control"] = ctrl - matching_elements.append(element) - return matching_elements, locators - - def _determine_search_criteria(self, locator: str) -> Any: - """Check search criteria from locator. - - Possible search criterias: - - name - - class / class_name - - type / control_type - - id / automation_id - - partial name (wildcard search for 'name' attribute) - - any (if none was defined) - - :param locator: name of the locator - :return: criteria and locator - """ - if locator.startswith("name:"): - search_criteria = "name" - _, locator = locator.split(":", 1) - elif locator.startswith(("class_name:", "class:")): - search_criteria = "class_name" - _, locator = locator.split(":", 1) - elif locator.startswith(("control_type:", "type:")): - search_criteria = "control_type" - _, locator = locator.split(":", 1) - elif locator.startswith(("automation_id:", "id:")): - search_criteria = "automation_id" - _, locator = locator.split(":", 1) - elif locator.startswith("partial name:"): - search_criteria = "partial name" - _, locator = locator.split(":", 1) - elif locator.startswith("regexp:"): - search_criteria = "regexp" - _, locator = locator.split(":", 1) - else: - search_criteria = "any" - - return search_criteria, locator - - # TODO: supporting multiple search criterias at same time to identify ONE element - def _is_element_matching( - self, itemdict: dict, locator: str, criteria: str, wildcard: bool = False - ) -> bool: - if criteria == "regexp": - name_search = re.search(locator, itemdict["name"]) - class_search = re.search(locator, itemdict["class_name"]) - type_search = re.search(locator, itemdict["control_type"]) - id_search = re.search(locator, itemdict["automation_id"]) - return name_search or class_search or type_search or id_search - elif criteria != "any" and criteria in itemdict: - if (wildcard and locator in itemdict[criteria]) or ( - locator == itemdict[criteria] - ): - return True - elif criteria == "any": - name_search = self.is_element_matching(itemdict, locator, "name") - class_search = self.is_element_matching(itemdict, locator, "class_name") - type_search = self.is_element_matching(itemdict, locator, "control_type") - id_search = self.is_element_matching(itemdict, locator, "automation_id") - if name_search or class_search or type_search or id_search: - return True - elif criteria == "partial name": - return self.is_element_matching(itemdict, locator, "name", True) - return False - - # TODO: supporting multiple search criterias at same time to identify ONE element - def is_element_matching( - self, itemdict: dict, locator: str, criteria: str, wildcard: bool = False - ) -> bool: - """Is element matching. Check if locator is found in `any` field - or `criteria` field in the window items. - - :param itemDict: dictionary of element items - :param locator: name of the locator - :param criteria: criteria on which to match element - :param wildcard: whether to do reg exp match or not, default False - :return: True if element is matching locator and criteria, False if not - """ - return self._is_element_matching(itemdict, locator, criteria, wildcard) - - def get_dialog_rectangle(self, ctrl: Any = None, as_dict: bool = False) -> Any: - """Get dialog rectangle coordinates - - If `ctrl` is None then get coordinates from `dialog` - - :param ctrl: name of the window control object, defaults to None - :return: coordinates: left, top, right, bottom - - Example: - - .. code-block:: robotframework - - ${left} ${top} ${right} ${bottom}= Get Dialog Rectangle - &{coords} Get Dialog Rectangle as_dict=True - Log top=${coords.top} left=${coords.left} - - """ - if ctrl: - rect = ctrl.element_info.rectangle - elif self.dlg: - rect = self.dlg.element_info.rectangle - else: - raise ValueError("No dialog open") - - if as_dict: - return { - "left": rect.left, - "top": rect.top, - "right": rect.right, - "bottom": rect.bottom, - } - else: - return rect.left, rect.top, rect.right, rect.bottom - - def get_element_center(self, element: dict) -> Any: - """Get element center coordinates - - :param element: dictionary of element items - :return: coordinates, x and y - - Example: - - .. code-block:: robotframework - - @{element} Find Element CalculatorResults - ${x} ${y}= Get Element Center ${elements[0][0]} - - """ - return self.calculate_rectangle_center(element["rectangle"]) - - def click_type( - self, x: int = None, y: int = None, click_type: str = "click" - ) -> None: - """Mouse click on coordinates x and y. - - Default click type is `click` meaning `left` - - :param x: horizontal coordinate for click, defaults to None - :param y: vertical coordinate for click, defaults to None - :param click_type: "click", "right" or "double", defaults to "click" - :raises ValueError: if coordinates are not valid - - Example: - - .. code-block:: robotframework - - Click Type x=450 y=100 - Click Type x=450 y=100 click_type=right - Click Type x=450 y=100 click_type=double - - """ - self.logger.info("Click type '%s' at (%s, %s)", click_type, x, y) - if (x is None and y is None) or (x < 0 or y < 0): - raise ValueError(f"Can't click on given coordinates: ({x}, {y})") - if click_type == "click": - pywinauto.mouse.click(coords=(x, y)) - elif click_type == "double": - pywinauto.mouse.double_click(coords=(x, y)) - elif click_type == "right": - pywinauto.mouse.right_click(coords=(x, y)) - - def get_window_elements( - self, - screenshot: bool = False, - element_json: bool = False, - outline: bool = False, - ) -> Any: - # pylint: disable=C0301 - """Get element information about all window dialog controls - and their descendants. - - :param screenshot: save element screenshot if True, defaults to False - :param element_json: save element json if True, defaults to False - :param outline: highlight elements if True, defaults to False - :return: all controls and all elements - - Example: - - .. code-block:: robotframework - - @{elements} Get Window Elements - Log Many ${elements[0]} # list of all available locators - Log Many ${elements[1]} # list of matching elements - @{elements} Get Window Elements screenshot=True element_json=True outline=True - - """ # noqa: E501 - ctrls = self._get_all_window_controls() - elements, controls = [], [] - for _, ctrl in enumerate(ctrls): - try: - if not hasattr(ctrl, "element_info"): - continue - except COMError as ce: - self.logger.info("Got COM error: %s", str(ce)) - continue - - filename = clean_filename( - f"locator_{self.windowtitle}_ctrl_{ctrl.element_info.name}" - ) - - if screenshot and len(ctrl.element_info.name) > 0: - self.screenshot(filename, ctrl=ctrl, overwrite=True) - if outline: - ctrl.draw_outline(colour="red", thickness=4) - delay(0.2) - ctrl.draw_outline(colour=0x000000, thickness=4) - - element = self._parse_element_attributes(element=ctrl) - if element_json: - write_element_info_as_json(element, filename) - - controls.append(ctrl) - elements.append(element) - - if element_json: - write_element_info_as_json( - elements, clean_filename(f"locator_{self.windowtitle}_all_elements") - ) - - return controls, elements - - def _get_all_window_controls(self): - if self.dlg is None: - raise ValueError("No dialog open") - - ctrls = [self.dlg] - try: - if hasattr(self.dlg, "descendants"): - ctrls += self.dlg.descendants() - except COMError as ce: - self.logger.info("Got COM error: %s", str(ce)) - return ctrls - - def _get_element_coordinates(self, element: Any) -> Any: - """Get element coordinates from pywinauto object. - - :param rectangle: item containing rectangle information - :return: coordinates: left, top, right, bottom - """ - left = 0 - top = 0 - right = 0 - bottom = 0 - if isinstance(element, pywinauto.win32structures.RECT): - left = element.left - top = element.top - right = element.right - bottom = element.bottom - elif isinstance(element, dict) and "rectangle" not in element.keys(): - left = element.left - top = element.top - right = element.right - bottom = element.bottom - else: - if isinstance(element, dict) and "rectangle" in element.keys(): - rectangle = element["rectangle"] - else: - rectangle = element - left, top, right, bottom = map( - int, - re.match( - r"\(L([-]?\d+).*T([-]?\d+).*R([-]?\d+).*B([-]?\d+)\)", - str(rectangle), - ).groups(), - ) - return left, top, right, bottom - - def screenshot( - self, - filename: str, - element: dict = None, - ctrl: Any = None, - desktop: bool = False, - overwrite: bool = True, - ) -> None: - """Save screenshot into filename. - - :param filename: name of the file - :param element: take element screenshot, defaults to None - :param ctrl: take control screenshot, defaults to None - :param desktop: take desktop screenshot if True, defaults to False - :param overwrite: overwrite existing image (deprecated, always True) - - Example: - - .. code-block:: robotframework - - @{element} Find Element CalculatorResults - Screenshot element.png ${elements[0][0]} - Screenshot desktop.png desktop=True - Screenshot desktop.png desktop=True overwrite=True - - """ - del overwrite # Always overwrite - - if desktop: - region = None - elif element: - region = self._get_element_coordinates(element["rectangle"]) - elif ctrl: - region = self.get_dialog_rectangle(ctrl) - else: - region = self.get_dialog_rectangle() - - if region is not None: - region = Region(*region) - - Desktop().take_screenshot(path=filename, locator=region) - - def _parse_element_attributes(self, element: dict) -> dict: - """Return filtered element dictionary for an element. - - :param element: should contain `element_info` attribute - :return: dictionary containing element attributes - """ - if element is None and "element_info" not in element: - self.logger.warning( - "%s is none or does not have element_info attribute", element - ) - return None - - element_dict = self._prepare_element_dict(element) - element_info = element.element_info - element_attributes = [a for a in dir(element_info) if not a.startswith("_")] - - for attr in element_attributes: - try: - attr_value = getattr(element_info, attr) - if attr == "parent": - element_dict["parent"] = getattr(attr_value, "control_type", None) - else: - element_dict[attr] = ( - attr_value() if callable(attr_value) else str(attr_value) - ) - except TypeError: - pass - except NotImplementedError: - pass - except COMError as ce: - self.logger.info("Got COM error: %s", str(ce)) - - return self._clean_element_dict(element_dict) - - def _prepare_element_dict(self, element): - element_dict = {} - - element_dict["object"] = element - try: - element_dict["legacy"] = ( - element.legacy_properties() - if hasattr(element, "legacy_properties") - else None - ) - except AttributeError: - pass - return element_dict - - def _clean_element_dict(self, element_dict): - attributes_to_remove = [ - # "automation_id", - "children", - # "class_name", - # "control_id", - # "control_type", - "descendants", - "dump_window", - "element", - # "enabled", - "filter_with_depth", - "framework_id", - "from_point", - # "handle", - "has_depth", - "iter_children", - "iter_descendants", - # "name", - # "parent", - # "process_id", - # "rectangle", - # "rich_text", - # "runtime_id", - "set_cache_strategy", - "top_from_point", - # "visible", - ] - - for attr in attributes_to_remove: - element_dict.pop(attr, None) - - return element_dict - - def put_system_to_sleep(self) -> None: - """Put Windows into sleep mode - - Example: - - .. code-block:: robotframework - - Put System To Sleep - - """ - access = win32security.TOKEN_ADJUST_PRIVILEGES | win32security.TOKEN_QUERY - htoken = win32security.OpenProcessToken(win32api.GetCurrentProcess(), access) - if htoken: - priv_id = win32security.LookupPrivilegeValue( - None, win32security.SE_SHUTDOWN_NAME - ) - win32security.AdjustTokenPrivileges( - htoken, 0, [(priv_id, win32security.SE_PRIVILEGE_ENABLED)] - ) - ctypes.windll.powrprof.SetSuspendState(False, True, True) - win32api.CloseHandle(htoken) - - def lock_screen(self) -> None: - """Put windows into lock mode - - Example: - - .. code-block:: robotframework - - Lock Screen - """ - ctypes.windll.User32.LockWorkStation() - - def log_in(self, username: str, password: str, domain: str = ".") -> str: - """Log into Windows `domain` with `username` and `password`. - - :param username: name of the user - :param password: password of the user - :param domain: windows domain for the user, defaults to "." - :return: handle - - Example: - - .. code-block:: robotframework - - Log In username=myname password=mypassword domain=company - """ - return win32security.LogonUser( - username, - domain, - password, - win32con.LOGON32_LOGON_INTERACTIVE, - win32con.LOGON32_PROVIDER_DEFAULT, - ) - - def _validate_target(self, target: dict, target_locator: str) -> Any: - target_x = target_y = 0 - if target_locator is not None: - self.switch_to_application(target["id"]) - target_elements, _ = self.find_element(target_locator) - if len(target_elements) == 0: - raise ValueError( - ("Target element was not found by locator '%s'" % target_locator) - ) - elif len(target_elements) > 1: - raise ValueError( - ( - "Target element matched more than 1 element (%d) " - "by locator '%s'" % (len(target_elements), target_locator) - ) - ) - target_x, target_y = self.calculate_rectangle_center( - target_elements[0]["rectangle"] - ) - else: - target_x, target_y = self.calculate_rectangle_center( - target["dlg"].rectangle() - ) - return target_x, target_y - - def _select_elements_for_drag( - self, src: dict, src_locator: str, origin="middle" - ) -> Any: - self.switch_to_application(src["id"]) - source_elements, _ = self.find_element(src_locator) - if len(source_elements) == 0: - raise ValueError( - ("Source elements where not found by locator '%s'", src_locator) - ) - selections = [] - source_min_left = 99999 - source_max_right = -1 - source_min_top = 99999 - source_max_bottom = -1 - for elem in source_elements: - left, top, right, bottom = self._get_element_coordinates(elem["rectangle"]) - if left < source_min_left: - source_min_left = left - if right > source_max_right: - source_max_right = right - if top < source_min_top: - source_min_top = top - if bottom > source_max_bottom: - source_max_bottom = bottom - mid_x = int((right - left) / 2) + left - mid_y = int((bottom - top) / 2) + top - if origin == "middle": - selections.append((mid_x, mid_y)) - elif origin == "topleft": - selections.append((left, top)) - source_x = int((source_max_right - source_min_left) / 2) + source_min_left - source_y = int((source_max_bottom - source_min_top) / 2) + source_min_top - return selections, source_x, source_y - - def drag_and_drop( - self, - src: Any, - target: Any, - src_locator: str, - target_locator: str = None, - handle_ctrl_key: bool = False, - drop_delay: float = 2.0, - origin: str = "middle", - ) -> None: - # pylint: disable=C0301 - """Drag elements from source and drop them on target. - - Please note that if CTRL is not pressed down during drag and drop then - operation is MOVE operation, on CTRL down the operation is COPY operation. - - There will be also overwrite notification if dropping over existing files. - - :param src: application object or instance id - :param target: application object or instance id - :param src_locator: elements to move - :param handle_ctrl_key: True if keyword should press CTRL down dragging - :param drop_delay: how many seconds to wait until releasing mouse drop, - default 2.0 - :raises ValueError: on validation errors - - Example: - - .. code-block:: robotframework - - ${app1}= Open Using Run Dialog explorer.exe{VK_SPACE}C:\\workfiles\\movethese movethese - ${app2}= Open Using Run Dialog wordpad.exe Document - WordPad - Drag And Drop ${app1} ${app2} regexp:testfile_\\d.txt name:Rich Text Window handle_ctrl_key=${True} - Drag And Drop ${app1} ${app1} regexp:testfile_\\d.txt name:subdir handle_ctrl_key=${True} - - """ # noqa : E501 - if isinstance(src, int): - src = self.get_app(src) - if isinstance(target, int): - target = self.get_app(target) - - single_application = src["app"] == target["app"] - selections, source_x, source_y = self._select_elements_for_drag( - src, src_locator, origin - ) - target_x, target_y = self._validate_target(target, target_locator) - - self.logger.info( - "Dragging %d elements from (%d,%d) to (%d,%d)", - len(selections), - source_x, - source_y, - target_x, - target_y, - ) - - try: - if handle_ctrl_key: - self.send_keys("{VK_LCONTROL down}") - delay(0.2) - - # Select elements by mouse clicking - if not single_application: - self.restore_dialog(src["windowtitle"]) - for idx, selection in enumerate(selections): - self.logger.debug("Selecting item %d by mouse_click", idx) - # pywinauto.mouse.click(coords=(selection[0]+5, selection[1]+5)) - self.mouse_click_coords(selection[0] + 5, selection[1] + 5) - - # Start drag from the last item - pywinauto.mouse.press(coords=(source_x, source_y)) - delay(0.5) - if not single_application: - self.restore_dialog(target["windowtitle"]) - pywinauto.mouse.move(coords=(target_x, target_y)) - - self.logger.debug("Cursor position: %s", win32api.GetCursorPos()) - delay(drop_delay) - self.mouse_click_coords(target_x, target_y) - pywinauto.mouse.click(coords=(target_x, target_y)) - - # if action_required: - self.send_keys("{ENTER}") - if handle_ctrl_key: - self.send_keys("{VK_LCONTROL up}") - delay(0.5) - # Deselect elements by mouse clicking - for selection in selections: - self.logger.debug("Deselecting item by mouse_click") - self.mouse_click_coords(selection[0] + 5, selection[1] + 5) - finally: - self.send_keys("{VK_LCONTROL up}") - - def calculate_rectangle_center(self, rectangle: Any) -> Any: - """Calculate x and y center coordinates from rectangle. - - :param rectangle: element rectangle coordinates - :return: x and y coordinates of rectangle center - - Example: - - .. code-block:: robotframework - - Open Using Run Dialog calc Calculator - &{rect}= Get Element Rectangle CalculatorResults - ${x} ${y}= Calculate Rectangle Center ${rect} - """ - left, top, right, bottom = self._get_element_coordinates(rectangle) - x = int((right - left) / 2) + left - y = int((bottom - top) / 2) + top - return x, y - - def get_window_list(self): - """Get list of open windows - - Window dictionaries contain: - - - automation_id - - control_id - - title - - pid - - handle - - is_active - - keyboard_focus - - rectangle - - :return: list of window dictionaries - - Example: - - .. code-block:: robotframework - - @{windows} Get Window List - FOR ${window} IN @{windows} - Log Many ${window} - END - """ - windows = pywinauto.Desktop(backend=self._backend).windows() - window_list = [] - for w in windows: - try: - left, top, right, bottom = self._get_element_coordinates(w.rectangle()) - window_list.append( - { - "automation_id": w.automation_id(), - "control_id": w.control_id(), - "title": w.window_text(), - "pid": w.process_id(), - "handle": w.handle, - "is_active": w.is_active(), - "keyboard_focus": w.has_keyboard_focus(), - "rectangle": [left, top, right, bottom], - "object": w, - } - ) - except Exception as e: # pylint: disable=broad-except - self.logger.debug(str(e)) - return window_list - - def refresh_window(self): - """Get controls and elements for current windows. - - Should be called always when window content changes on - Windows desktop. - - :return: controls (list) and elements (list) - """ - self.logger.debug("Refresh window") - controls, elements = self.get_window_elements() - self.elements = elements - self.controls = controls - return controls, elements - - def _parse_locator(self, locator: str, search_criteria: str): - regex = rf"({':|'.join(WINDOWS_LOCATOR_STRATEGIES.keys())}:|or|and)('{{1}}(.+)'{{1}}|(\S+))?" # noqa: E501 - parts = re.finditer(regex, locator, re.IGNORECASE) - - locators = [] - match_type = "all" - - for part in parts: - groups = part.groups() - if groups[0].lower() == "or": - match_type = "any" - elif groups[0].lower() == "and": - pass - else: - strategy, _ = groups[0].split(":") - value = groups[2] if groups[2] else groups[3] - locators.append([WINDOWS_LOCATOR_STRATEGIES[strategy], value]) - - # Add some strategies if there aren't other valid strategies - if not locators: - match_type = "any" - locators.append(["name", locator]) - locators.append(["automation_id", locator]) - locators.append([search_criteria, locator]) - return match_type, locators - - def set_automation_speed(self, speed: Speed = Speed.DEFAULT): - """Set global automation timings - - :param speed: possible values 'default', 'fast' or 'slow' - """ - speed = to_speed(speed) - - if speed == Speed.DEFAULT: - pywinauto.timings.Timings.defaults() - elif speed == Speed.SLOW: - pywinauto.timings.Timings.slow() - elif speed == Speed.FAST: - pywinauto.timings.Timings.fast() diff --git a/packages/main/src/RPA/Desktop/__init__.py b/packages/main/src/RPA/Desktop/__init__.py index e99af2f024..761329770a 100644 --- a/packages/main/src/RPA/Desktop/__init__.py +++ b/packages/main/src/RPA/Desktop/__init__.py @@ -48,7 +48,7 @@ class Desktop(DynamicCore): - Taking screenshots - Clipboard management - .. warning:: Windows element selectors are not currently supported, and require the use of ``RPA.Desktop.Windows`` + .. warning:: Windows element selectors are not currently supported, and require the use of ``RPA.Windows`` **Installation** diff --git a/packages/main/src/RPA/Desktop/keywords/screen.py b/packages/main/src/RPA/Desktop/keywords/screen.py index 39e78e23ac..af1708883d 100644 --- a/packages/main/src/RPA/Desktop/keywords/screen.py +++ b/packages/main/src/RPA/Desktop/keywords/screen.py @@ -168,22 +168,26 @@ def get_display_dimensions(self) -> Region: return _monitor_to_region(sct.monitors[0]) @keyword - def highlight_elements(self, locator: LocatorType): - """Draw an outline around all matching elements.""" + def highlight_elements(self, locator: LocatorType) -> List[Region]: + """Draw an outline around all matching elements, and return their regions.""" if not utils.is_windows(): raise NotImplementedError("Not supported on non-Windows platforms") matches = self.ctx.find_elements(locator) + regions = [] for match in matches: if isinstance(match, Region): - _draw_outline(match) + region = match elif isinstance(match, Point): # TODO: Draw a circle instead? region = Region(match.x - 5, match.y - 5, match.x + 5, match.y + 5) - _draw_outline(region) else: raise TypeError(f"Unknown location type: {match}") + _draw_outline(region) + regions.append(region) + + return regions @keyword def define_region(self, left: int, top: int, right: int, bottom: int) -> Region: diff --git a/packages/main/tests/python/test_desktop_screen.py b/packages/main/tests/python/test_desktop_screen.py new file mode 100644 index 0000000000..05fd476174 --- /dev/null +++ b/packages/main/tests/python/test_desktop_screen.py @@ -0,0 +1,37 @@ +"""Tests for RPA.Desktop.keywords.screen.""" +from unittest.mock import MagicMock, patch + +from RPA.core.geometry import Point, Region +from RPA.Desktop.keywords.screen import ScreenKeywords + + +def _make_keywords(): + return ScreenKeywords(MagicMock()) + + +def test_highlight_elements_returns_regions(): + keywords = _make_keywords() + region = Region(1, 2, 3, 4) + keywords.ctx.find_elements.return_value = [region] + + with patch( + "RPA.Desktop.keywords.screen.utils.is_windows", return_value=True + ), patch("RPA.Desktop.keywords.screen._draw_outline") as draw_outline: + result = keywords.highlight_elements("locator") + + assert result == [region] + draw_outline.assert_called_once_with(region) + + +def test_highlight_elements_converts_points_to_regions(): + keywords = _make_keywords() + point = Point(10, 20) + keywords.ctx.find_elements.return_value = [point] + + with patch( + "RPA.Desktop.keywords.screen.utils.is_windows", return_value=True + ), patch("RPA.Desktop.keywords.screen._draw_outline") as draw_outline: + result = keywords.highlight_elements("locator") + + assert result == [Region(5, 15, 15, 25)] + draw_outline.assert_called_once_with(Region(5, 15, 15, 25)) diff --git a/packages/main/uv.lock b/packages/main/uv.lock index a20f796c6b..3b61487702 100644 --- a/packages/main/uv.lock +++ b/packages/main/uv.lock @@ -2119,7 +2119,7 @@ wheels = [ [[package]] name = "rpaframework" -version = "32.0.2" +version = "33.0.0" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" },