From 0566e856a529dd428080d8a9b3908b501d14fca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Tue, 16 Jul 2024 19:27:03 -0300 Subject: [PATCH 01/78] feat - new elements abstractions --- fastrpa/__init__.py | 2 +- fastrpa/app.py | 69 +++++------ fastrpa/commons.py | 48 +------- fastrpa/core/elements.py | 247 +++++++++++++++++++++++++++++++++++++++ fastrpa/core/keyboard.py | 21 ++++ fastrpa/core/timer.py | 19 +++ fastrpa/elements/form.py | 101 ---------------- fastrpa/exceptions.py | 8 ++ fastrpa/factories.py | 57 +++++++++ fastrpa/settings.py | 4 + fastrpa/types.py | 12 ++ 11 files changed, 405 insertions(+), 183 deletions(-) create mode 100644 fastrpa/core/elements.py create mode 100644 fastrpa/core/keyboard.py create mode 100644 fastrpa/core/timer.py delete mode 100644 fastrpa/elements/form.py create mode 100644 fastrpa/factories.py create mode 100644 fastrpa/settings.py diff --git a/fastrpa/__init__.py b/fastrpa/__init__.py index de4592e..aa5b14e 100644 --- a/fastrpa/__init__.py +++ b/fastrpa/__init__.py @@ -1,5 +1,5 @@ from fastrpa.app import FastRPA, Web -from fastrpa.elements.form import Form +from fastrpa.core.form import Form from fastrpa.exceptions import ElementNotFound diff --git a/fastrpa/app.py b/fastrpa/app.py index 7ebd5c2..fa3090f 100644 --- a/fastrpa/app.py +++ b/fastrpa/app.py @@ -1,14 +1,13 @@ -from time import sleep -from selenium.webdriver import Remote, ChromeOptions, ActionChains -from selenium.webdriver.common.keys import Keys +from selenium.webdriver import Remote, ChromeOptions from fastrpa.commons import ( - get_element_text, get_browser_options, - wait_until_element_is_hidden, - wait_until_element_is_present, - VISIBILITY_TIMEOUT, ) -from fastrpa.elements.form import Form +from fastrpa.settings import VISIBILITY_TIMEOUT +from fastrpa.core.elements import Element + +from fastrpa.core.timer import Timer +from fastrpa.core.keyboard import Keyboard +from fastrpa.factories import ElementFactory from fastrpa.types import BrowserOptions, BrowserOptionsClass, WebDriver @@ -19,40 +18,42 @@ def __init__( webdriver: WebDriver, visibility_timeout: int, ): - self.url = url + self._keyboard: Keyboard | None = None + self._timer: Timer | None = None + self.starter_url = url self.webdriver = webdriver - self.webdriver.get(url) + self.webdriver.get(self.starter_url) + self._element_factory = ElementFactory(self.webdriver) self.visibility_timeout = visibility_timeout - def reset(self): - self.webdriver.get(self.url) - - def form(self, xpath: str) -> Form: - return Form(xpath, self.webdriver, self.visibility_timeout) - - def read(self, xpath: str) -> str | None: - return get_element_text(self.webdriver, xpath, self.visibility_timeout) - - def has_content(self, value: str) -> bool: - return value in self.webdriver.page_source - - def wait_until_hide(self, xpath: str): - wait_until_element_is_hidden(self.webdriver, xpath) + @property + def url(self) -> str: + return self.webdriver.current_url - def wait_until_present(self, xpath: str): - wait_until_element_is_present(self.webdriver, xpath) + @property + def keyboard(self) -> Keyboard: + if self._keyboard: + return self._keyboard + self._keyboard = Keyboard(self.webdriver) + return self._keyboard - def wait_seconds(self, seconds: int): - sleep(seconds) + @property + def timer(self) -> Timer: + if self._timer: + return self._timer + self._timer = Timer(self.webdriver) + return self._timer - def press_esc(self): - ActionChains(self.webdriver).send_keys(Keys.ESCAPE).perform() + def reset(self): + self.webdriver.get(self.starter_url) - def press_enter(self): - ActionChains(self.webdriver).send_keys(Keys.ENTER).perform() + def element(self, xpath: str, wait: bool = True) -> Element: + if not wait: + return self._element_factory.get(xpath) + return self._element_factory.get_when_available(xpath, self.visibility_timeout) - def press_tab(self): - ActionChains(self.webdriver).send_keys(Keys.TAB).perform() + def has_content(self, value: str) -> bool: + return value in self.webdriver.page_source class FastRPA: diff --git a/fastrpa/commons.py b/fastrpa/commons.py index 45e7c2e..65e8e9e 100644 --- a/fastrpa/commons.py +++ b/fastrpa/commons.py @@ -1,23 +1,17 @@ from selenium.webdriver import ChromeOptions -from selenium.common.exceptions import TimeoutException, ElementNotInteractableException from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.remote.webelement import WebElement -from selenium.webdriver.support.ui import Select import os import requests import mimetypes -from fastrpa.exceptions import ElementNotFound +from fastrpa.settings import HIDDEN_TIMEOUT from fastrpa.types import BrowserOptions, BrowserOptionsClass, WebDriver -VISIBILITY_TIMEOUT = 10 -HIDDEN_TIMEOUT = 60 - - def get_browser_options( options: list[str], options_class: BrowserOptionsClass = ChromeOptions ) -> BrowserOptions: @@ -27,25 +21,6 @@ def get_browser_options( return instance -def get_element( - webdriver_or_parent_node: WebDriver | WebElement, - xpath: str, - timeout: int = VISIBILITY_TIMEOUT, -) -> WebElement: - try: - return WebDriverWait(webdriver_or_parent_node, timeout).until( - expected_conditions.presence_of_element_located((By.XPATH, xpath)) - ) - - except ElementNotInteractableException: - return WebDriverWait(webdriver_or_parent_node, timeout).until( - expected_conditions.element_to_be_clickable((By.XPATH, xpath)) - ) - - except TimeoutException: - raise ElementNotFound(xpath, timeout) - - def wait_until_element_is_hidden( webdriver_or_parent_node: WebDriver | WebElement, xpath: str, @@ -66,27 +41,6 @@ def wait_until_element_is_present( ) -def get_select_element( - webdriver_or_parent_node: WebDriver | WebElement, - xpath: str, - timeout: int = VISIBILITY_TIMEOUT, -) -> Select: - return Select(get_element(webdriver_or_parent_node, xpath, timeout)) - - -def get_element_text( - webdriver_or_parent_node: WebDriver | WebElement, - xpath: str, - timeout: int = VISIBILITY_TIMEOUT, -) -> str | None: - element = get_element(webdriver_or_parent_node, xpath, timeout) - if element.text: - return element.text - elif value := element.get_attribute("value"): - return value - return None - - def get_file_path(path: str) -> str: if os.path.isfile(path): return path diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py new file mode 100644 index 0000000..bfd0354 --- /dev/null +++ b/fastrpa/core/elements.py @@ -0,0 +1,247 @@ +from time import sleep +from typing import Any +from selenium.webdriver import ActionChains +from selenium.webdriver.support.ui import Select +from selenium.webdriver.common.by import By + +from fastrpa.commons import get_file_path +from fastrpa.dataclasses import Item, Option +from fastrpa.types import WebDriver, WebElement + + +class Element: + _tag: str | None = None + _id: str | None = None + _css_class: str | None = None + _text: str | None = None + _is_visible: bool | None = None + _actions: ActionChains | None = None + + def __init__(self, element: WebElement, webdriver: WebDriver) -> None: + self.element = element + self.webdriver = webdriver + + @property + def actions(self) -> ActionChains: + if self._actions is None: + self._actions = ActionChains(self.webdriver) + return self._actions + + @property + def tag(self) -> str: + if self._tag is None: + self._tag = self.element.tag_name + return self._tag + + @property + def id(self) -> str | None: + if self._id is None: + self._id = self.element.get_attribute("id") + return self._id + + @property + def css_class(self) -> str | None: + if self._css_class is None: + self._css_class = self.element.get_attribute("class") + return self._css_class + + @property + def text(self) -> str | None: + if self._text is None: + if self.element.text: + self._text = self.element.text + elif value := self.element.get_attribute("value"): + self._text = value + else: + self._text = None + return self._text + + @property + def is_visible(self) -> bool: + if self._is_visible is None: + self._is_visible = self.element.is_displayed() + return self._is_visible + + def focus(self): + self.actions.scroll_to_element(self.element) + self.actions.move_to_element(self.element) + self.actions.perform() + + def check(self, attribute: str, value: str) -> bool: + return self.element.get_attribute(attribute) == value + + +class InputElement(Element): + + def clear(self): + self.element.clear() + + def fill(self, value: str): + self.element.send_keys(value) + + def fill_slowly(self, value: str, delay: float = 0.3): + for key in value: + self.element.send_keys(key) + sleep(delay) + + +class FileInputElement(Element): + def attach_file(self, path: str): + self.element.send_keys(get_file_path(path)) + + +class SelectElement(Element): + _select_element: Select | None = None + _options: list[Option] | None = None + _options_values: list[str | None] | None = None + _options_labels: list[str | None] | None = None + + @property + def select_element(self) -> Select: + if not self._select_element: + self._select_element = Select(self.element) + return self._select_element + + @property + def options_values(self) -> list[str | None]: + if self._options_values is None: + self._options_values = [ + option.get_attribute("value") + for option in self.select_element.options + ] + return self._options_values + + @property + def options_labels(self) -> list[str | None]: + if self._options_labels is None: + self._options_labels = [ + option.get_attribute("innerText") + for option in self.select_element.options + ] + return self._options_labels + + @property + def options(self) -> list[Option]: + if self._options is None: + self._options = [ + Option(option.get_attribute("value"), option.get_attribute("innerText")) + for option in self.select_element.options + ] + return self._options + + def select( + self, label: str | None = None, value: str | None = None + ): + if label: + self.select_element.select_by_visible_text(label) + elif value: + self.select_element.select_by_value(value) + else: + raise ValueError('You must provide at least "label" or "value"!') + + def has_option(self, label: str | None = None, value: str | None = None): + if label: + return label in self.options_labels + elif value: + return value in self.options_values + else: + raise ValueError('You must provide at least "label" or "value"!') + + def __contains__(self, key: Any) -> bool: + return self.has_option(label=key) or self.has_option(value=key) + + +class ListElement(Element): + _is_ordered: bool | None = None + _items_elements: list[WebElement] | None = None + _items: list[Item] | None = None + _items_ids: list[str | None] | None = None + _items_labels: list[str | None] | None = None + + @property + def items_elements(self) -> list[WebElement]: + if self._items_elements is None: + self._items_elements = self.element.find_elements(By.XPATH, "./li") + return self._items_elements + + @property + def is_ordered(self) -> bool: + if self._is_ordered is None: + self._is_ordered = (self.element.tag_name == 'ol') + return self._is_ordered + + @property + def items(self) -> list[Item]: + if self._items is None: + self._items = [ + Item(item.get_attribute("id"), item.get_attribute("innerText")) + for item in self.items_elements + ] + return self._items + + @property + def items_ids(self) -> list[str | None]: + if not self._items_ids: + self._items_ids = [ + item.get_attribute("ids") + for item in self.items_elements + ] + return self._items_ids + + @property + def items_labels(self) -> list[str | None]: + if self._items_labels is None: + self._items_labels = [ + item.get_attribute("innerText") + for item in self.items_elements + ] + return self._items_labels + + def click_in_item(self, label: str | None = None, id: str | None = None): + if not (label or id): + raise ValueError('You must provide at least "label" or "id"!') + + for item in self.items_elements: + if label and label == item.get_attribute("innerText"): + self.actions.click(item) + self.actions.perform() + return + + if id and id == item.get_attribute("id"): + self.actions.click(item) + self.actions.perform() + return + + raise ValueError("Item not found!") + + def has_item(self, label: str | None = None, id: str | None = None): + if label: + return label in self.items_labels + elif id: + return id in self.items_ids + else: + raise ValueError('You must provide at least "label" or "id"!') + + def __contains__(self, key: Any) -> bool: + return self.has_item(label=key) or self.has_item(id=key) + + +class ButtonElement(Element): + + def click(self): + self.actions.move_to_element(self.element) + self.actions.click(self.element) + self.actions.perform() + + def double_click(self): + self.actions.move_to_element(self.element) + self.actions.double_click(self.element) + self.actions.perform() + + +class FormElement(Element): + def submit(self, button: ButtonElement | None = None): + if not button: + self.element.submit() + else: + button.click() diff --git a/fastrpa/core/keyboard.py b/fastrpa/core/keyboard.py new file mode 100644 index 0000000..2c8270f --- /dev/null +++ b/fastrpa/core/keyboard.py @@ -0,0 +1,21 @@ +from fastrpa.types import WebDriver, Keys, ActionChains + + +class Keyboard: + + def __init__(self, webdriver: WebDriver): + self.webdriver = webdriver + + def _press_key(self, key: str): + action = ActionChains(self.webdriver) + action.send_keys(key) + action.perform() + + def press_esc(self): + self._press_key(Keys.ESCAPE) + + def press_tab(self): + self._press_key(Keys.TAB) + + def press_enter(self): + self._press_key(Keys.ENTER) diff --git a/fastrpa/core/timer.py b/fastrpa/core/timer.py new file mode 100644 index 0000000..a2be4e0 --- /dev/null +++ b/fastrpa/core/timer.py @@ -0,0 +1,19 @@ +from time import sleep + +from fastrpa.commons import wait_until_element_is_hidden, wait_until_element_is_present +from fastrpa.types import WebDriver + + +class Timer: + + def __init__(self, webdriver: WebDriver): + self.webdriver = webdriver + + def wait_until_hide(self, xpath: str): + wait_until_element_is_hidden(self.webdriver, xpath) + + def wait_until_present(self, xpath: str): + wait_until_element_is_present(self.webdriver, xpath) + + def wait_seconds(self, seconds: int): + sleep(seconds) diff --git a/fastrpa/elements/form.py b/fastrpa/elements/form.py deleted file mode 100644 index 0698601..0000000 --- a/fastrpa/elements/form.py +++ /dev/null @@ -1,101 +0,0 @@ -from selenium.webdriver import ActionChains -from selenium.webdriver.common.by import By -from selenium.webdriver.remote.webelement import WebElement -from fastrpa.commons import ( - get_element, - get_select_element, - get_file_path, -) -from fastrpa.dataclasses import Item, Option -from fastrpa.types import WebDriver - - -class Form: - def __init__(self, xpath: str, webdriver: WebDriver, visibility_timeout: int): - self._element: WebElement | None = None - self.xpath = xpath - self.webdriver = webdriver - self.visibility_timeout = visibility_timeout - - @property - def element(self) -> WebElement: - return get_element(self.webdriver, self.xpath, self.visibility_timeout) - - def _get_child_element(self, xpath: str) -> WebElement: - return get_element(self.element, xpath, self.visibility_timeout) - - def fill(self, xpath: str, value: str): - input_element = self._get_child_element(xpath) - input_element.send_keys(value) - - def click(self, xpath: str): - action = ActionChains(self.webdriver) - action.click(self._get_child_element(xpath)) - action.perform() - - def attach_file(self, xpath: str, path: str): - input_element = self._get_child_element(xpath) - input_element.send_keys(get_file_path(path)) - - def get_select_options(self, xpath: str) -> list[Option]: - select_element = self._get_child_element(xpath) - options_list = select_element.find_elements(By.XPATH, "./option") - return [ - Option(option.get_attribute("value"), option.get_attribute("innerText")) - for option in options_list - ] - - def select_option( - self, xpath: str, label: str | None = None, value: str | None = None - ): - select_element = get_select_element( - self.element, xpath, self.visibility_timeout - ) - if label: - select_element.select_by_visible_text(label) - elif value: - select_element.select_by_value(value) - else: - raise ValueError('You must provide at least "label" or "value"!') - - def get_list_items(self, xpath: str) -> list[Item]: - list_element = self._get_child_element(xpath) - list_items = list_element.find_elements(By.XPATH, "./li") - return [ - Item(item.get_attribute("id"), item.get_attribute("innerText")) - for item in list_items - ] - - def is_visible(self, xpath: str) -> bool: - element = self._get_child_element(xpath) - return element.is_displayed() - - def select_list_item( - self, xpath: str, label: str | None = None, id: str | None = None - ): - list_element = self._get_child_element(xpath) - list_items = list_element.find_elements(By.XPATH, "./li") - actions = ActionChains(self.webdriver) - - if not (id or label): - raise ValueError('You must provide at least "label" or "id"!') - - for item in list_items: - if label and label == item.get_attribute("innerText"): - actions.click(item) - actions.perform() - return - - if id and id == item.get_attribute("id"): - actions.click(item) - actions.perform() - return - - raise ValueError("Item not found!") - - def submit(self, button_xpath: str | None = None): - if not button_xpath: - self.element.submit() - else: - button = get_element(self.element, button_xpath, self.visibility_timeout) - button.click() diff --git a/fastrpa/exceptions.py b/fastrpa/exceptions.py index 5ea1585..298cc4c 100644 --- a/fastrpa/exceptions.py +++ b/fastrpa/exceptions.py @@ -1,4 +1,12 @@ + class ElementNotFound(Exception): + message = "Element [{}] not found!" + + def __init__(self, xpath: str) -> None: + super().__init__(self.message.format(xpath)) + + +class ElementNotFoundAfterTime(Exception): message = "Element [{}] not found after {} seconds!" def __init__(self, xpath: str, timeout: int) -> None: diff --git a/fastrpa/factories.py b/fastrpa/factories.py new file mode 100644 index 0000000..258d2ee --- /dev/null +++ b/fastrpa/factories.py @@ -0,0 +1,57 @@ +from typing import Type +from selenium.common.exceptions import TimeoutException, NoSuchElementException +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.remote.webelement import WebElement + +from fastrpa.core.elements import ButtonElement, Element, FileInputElement, FormElement, InputElement, ListElement, SelectElement +from fastrpa.exceptions import ElementNotFound, ElementNotFoundAfterTime +from fastrpa.types import WebDriver + + +class ElementFactory: + + def __init__(self, webdriver: WebDriver): + self.webdriver = webdriver + + def element_class(self, element: WebElement) -> Type[Element]: + if element.tag_name in ['a', 'button']: + return ButtonElement + + if element.tag_name in ['ol', 'ul']: + return ListElement + + elif all([element.tag_name == 'input', element.get_attribute('type') == 'file']): + return FileInputElement + + elif element.tag_name == 'input': + return InputElement + + elif element.tag_name == 'select': + return SelectElement + + elif element.tag_name == 'form': + return FormElement + + return Element + + def get_when_available(self, xpath: str, timeout: int = 15) -> Element: + try: + selenium_element = WebDriverWait(self.webdriver, timeout).until( + expected_conditions.presence_of_element_located((By.XPATH, xpath)) + ) + element_class = self.element_class(selenium_element) + return element_class(selenium_element, self.webdriver) + + except TimeoutException: + raise ElementNotFoundAfterTime(xpath, timeout) + + def get(self, xpath: str) -> Element: + try: + selenium_element = self.webdriver.find_element(By.XPATH, xpath) + element_class = self.element_class(selenium_element) + return element_class(selenium_element, self.webdriver) + + except NoSuchElementException: + raise ElementNotFound(xpath) diff --git a/fastrpa/settings.py b/fastrpa/settings.py new file mode 100644 index 0000000..a58ca8a --- /dev/null +++ b/fastrpa/settings.py @@ -0,0 +1,4 @@ + + +VISIBILITY_TIMEOUT = 10 +HIDDEN_TIMEOUT = 60 diff --git a/fastrpa/types.py b/fastrpa/types.py index cf98ff6..82bb3da 100644 --- a/fastrpa/types.py +++ b/fastrpa/types.py @@ -1,5 +1,8 @@ from typing import Type +from selenium.webdriver.remote.webelement import WebElement +from selenium.webdriver.common.keys import Keys from selenium.webdriver import ( + ActionChains, ChromeOptions, SafariOptions, FirefoxOptions, @@ -13,3 +16,12 @@ WebDriver = Remote | Chrome | Safari | Firefox BrowserOptions = ChromeOptions | SafariOptions | FirefoxOptions BrowserOptionsClass = Type[ChromeOptions] | Type[SafariOptions] | Type[FirefoxOptions] + +__all__ = ( + 'ActionChains', + 'WebDriver', + 'WebElement', + 'Keys', + 'BrowserOptions', + 'BrowserOptionsClass', +) From b1afd11f2e18ddf9c6333701ef8c56eced64ad3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Tue, 16 Jul 2024 20:25:47 -0300 Subject: [PATCH 02/78] feat - table abstraction --- fastrpa/__init__.py | 11 ++-- fastrpa/app.py | 2 +- fastrpa/commons.py | 6 +- fastrpa/core/elements.py | 137 +++++++++++++++++++++++++++------------ fastrpa/core/keyboard.py | 3 +- fastrpa/core/timer.py | 1 - fastrpa/exceptions.py | 5 +- fastrpa/factories.py | 35 ++++++---- fastrpa/settings.py | 2 - pyproject.toml | 6 +- 10 files changed, 137 insertions(+), 71 deletions(-) diff --git a/fastrpa/__init__.py b/fastrpa/__init__.py index aa5b14e..3ca4cca 100644 --- a/fastrpa/__init__.py +++ b/fastrpa/__init__.py @@ -1,11 +1,10 @@ from fastrpa.app import FastRPA, Web -from fastrpa.core.form import Form -from fastrpa.exceptions import ElementNotFound +from fastrpa.exceptions import ElementNotFound, ElementNotFoundAfterTime __all__ = ( - "FastRPA", - "Web", - "Form", - "ElementNotFound", + 'FastRPA', + 'Web', + 'ElementNotFound', + 'ElementNotFoundAfterTime', ) diff --git a/fastrpa/app.py b/fastrpa/app.py index fa3090f..54f1c93 100644 --- a/fastrpa/app.py +++ b/fastrpa/app.py @@ -57,7 +57,7 @@ def has_content(self, value: str) -> bool: class FastRPA: - browser_arguments = ["--start-maximized", "--ignore-certificate-errors"] + browser_arguments = ['--start-maximized', '--ignore-certificate-errors'] def __init__( self, diff --git a/fastrpa/commons.py b/fastrpa/commons.py index 65e8e9e..f5091f1 100644 --- a/fastrpa/commons.py +++ b/fastrpa/commons.py @@ -46,11 +46,11 @@ def get_file_path(path: str) -> str: return path file_response = requests.get(path) - file_extension = mimetypes.guess_extension(file_response.headers["Content-Type"]) + file_extension = mimetypes.guess_extension(file_response.headers['Content-Type']) file_hash = abs(hash(file_response.content)) - download_path = f"/tmp/{file_hash}{file_extension}" + download_path = f'/tmp/{file_hash}{file_extension}' - with open(download_path, "wb") as file: + with open(download_path, 'wb') as file: file.write(file_response.content) return download_path diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index bfd0354..adce72c 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -36,13 +36,13 @@ def tag(self) -> str: @property def id(self) -> str | None: if self._id is None: - self._id = self.element.get_attribute("id") + self._id = self.element.get_attribute('id') return self._id @property def css_class(self) -> str | None: if self._css_class is None: - self._css_class = self.element.get_attribute("class") + self._css_class = self.element.get_attribute('class') return self._css_class @property @@ -50,7 +50,7 @@ def text(self) -> str | None: if self._text is None: if self.element.text: self._text = self.element.text - elif value := self.element.get_attribute("value"): + elif value := self.element.get_attribute('value'): self._text = value else: self._text = None @@ -61,18 +61,17 @@ def is_visible(self) -> bool: if self._is_visible is None: self._is_visible = self.element.is_displayed() return self._is_visible - + def focus(self): self.actions.scroll_to_element(self.element) self.actions.move_to_element(self.element) self.actions.perform() - + def check(self, attribute: str, value: str) -> bool: return self.element.get_attribute(attribute) == value class InputElement(Element): - def clear(self): self.element.clear() @@ -95,27 +94,26 @@ class SelectElement(Element): _options: list[Option] | None = None _options_values: list[str | None] | None = None _options_labels: list[str | None] | None = None - + @property def select_element(self) -> Select: if not self._select_element: self._select_element = Select(self.element) return self._select_element - + @property def options_values(self) -> list[str | None]: if self._options_values is None: self._options_values = [ - option.get_attribute("value") - for option in self.select_element.options + option.get_attribute('value') for option in self.select_element.options ] return self._options_values - + @property def options_labels(self) -> list[str | None]: if self._options_labels is None: self._options_labels = [ - option.get_attribute("innerText") + option.get_attribute('innerText') for option in self.select_element.options ] return self._options_labels @@ -124,32 +122,29 @@ def options_labels(self) -> list[str | None]: def options(self) -> list[Option]: if self._options is None: self._options = [ - Option(option.get_attribute("value"), option.get_attribute("innerText")) + Option(option.get_attribute('value'), option.get_attribute('innerText')) for option in self.select_element.options ] return self._options - - def select( - self, label: str | None = None, value: str | None = None - ): + + def select(self, label: str | None = None, value: str | None = None): if label: self.select_element.select_by_visible_text(label) elif value: self.select_element.select_by_value(value) else: raise ValueError('You must provide at least "label" or "value"!') - + def has_option(self, label: str | None = None, value: str | None = None): if label: return label in self.options_labels elif value: return value in self.options_values - else: - raise ValueError('You must provide at least "label" or "value"!') - + raise ValueError('You must provide at least "label" or "value"!') + def __contains__(self, key: Any) -> bool: return self.has_option(label=key) or self.has_option(value=key) - + class ListElement(Element): _is_ordered: bool | None = None @@ -161,73 +156,69 @@ class ListElement(Element): @property def items_elements(self) -> list[WebElement]: if self._items_elements is None: - self._items_elements = self.element.find_elements(By.XPATH, "./li") + self._items_elements = self.element.find_elements(By.XPATH, './/li') return self._items_elements @property def is_ordered(self) -> bool: if self._is_ordered is None: - self._is_ordered = (self.element.tag_name == 'ol') + self._is_ordered = self.element.tag_name == 'ol' return self._is_ordered - + @property def items(self) -> list[Item]: if self._items is None: self._items = [ - Item(item.get_attribute("id"), item.get_attribute("innerText")) + Item(item.get_attribute('id'), item.get_attribute('innerText')) for item in self.items_elements ] return self._items - + @property def items_ids(self) -> list[str | None]: if not self._items_ids: self._items_ids = [ - item.get_attribute("ids") - for item in self.items_elements + item.get_attribute('ids') for item in self.items_elements ] return self._items_ids - + @property def items_labels(self) -> list[str | None]: if self._items_labels is None: self._items_labels = [ - item.get_attribute("innerText") - for item in self.items_elements + item.get_attribute('innerText') for item in self.items_elements ] return self._items_labels - + def click_in_item(self, label: str | None = None, id: str | None = None): if not (label or id): raise ValueError('You must provide at least "label" or "id"!') for item in self.items_elements: - if label and label == item.get_attribute("innerText"): + if label and label == item.get_attribute('innerText'): self.actions.click(item) self.actions.perform() return - if id and id == item.get_attribute("id"): + if id and id == item.get_attribute('id'): self.actions.click(item) self.actions.perform() return - raise ValueError("Item not found!") - + raise ValueError('Item not found!') + def has_item(self, label: str | None = None, id: str | None = None): if label: return label in self.items_labels elif id: return id in self.items_ids - else: - raise ValueError('You must provide at least "label" or "id"!') - + raise ValueError('You must provide at least "label" or "id"!') + def __contains__(self, key: Any) -> bool: return self.has_item(label=key) or self.has_item(id=key) class ButtonElement(Element): - def click(self): self.actions.move_to_element(self.element) self.actions.click(self.element) @@ -245,3 +236,67 @@ def submit(self, button: ButtonElement | None = None): self.element.submit() else: button.click() + + +class TableElement(Element): + _headers: list[str | None] | None = None + _headers_elements: list[WebElement] | None = None + _rows: list[list[str | None]] | None = None + _rows_elements: list[WebElement] | None = None + + @property + def headers_elements(self) -> list[WebElement]: + if self._headers_elements is None: + first_row = self.element.find_element(By.XPATH, './/tr') + self._headers_elements = first_row.find_elements(By.XPATH, './/th') + return self._headers_elements + + @property + def headers(self) -> list[str | None]: + if self._headers is None: + self._headers = [ + header.get_attribute('innerText') if header else None + for header in self.headers_elements + ] + return self._headers + + @property + def rows_elements(self) -> list[WebElement]: + if self._rows_elements is None: + rows = self.element.find_elements(By.XPATH, './/tr') + if self.headers: + del rows[0] + self._rows_elements = rows + return self._rows_elements + + @property + def rows(self) -> list[list[str | None]]: + if self._rows is None: + rows_content = [] + for element in self.rows_elements: + rows_content.append( + [ + cell.get_attribute('innerText') + for cell in element.find_elements(By.XPATH, './/td') + ] + ) + self._rows = rows_content + return self._rows + + def column_values( + self, name: str | None = None, index: int | None = None + ) -> list[str | None]: + if (not name) and (index is None): + raise ValueError('You must provide at least "name" or "index"!') + + if index is None: + index = self.headers.index(name) + + return [row[index] for row in self.rows] + + def has_content(self, value: str) -> bool: + cells_content = [ + cell.get_attribute('innerText') + for cell in self.element.find_elements('.//td') + ] + return value in cells_content diff --git a/fastrpa/core/keyboard.py b/fastrpa/core/keyboard.py index 2c8270f..edd9ddc 100644 --- a/fastrpa/core/keyboard.py +++ b/fastrpa/core/keyboard.py @@ -2,7 +2,6 @@ class Keyboard: - def __init__(self, webdriver: WebDriver): self.webdriver = webdriver @@ -13,7 +12,7 @@ def _press_key(self, key: str): def press_esc(self): self._press_key(Keys.ESCAPE) - + def press_tab(self): self._press_key(Keys.TAB) diff --git a/fastrpa/core/timer.py b/fastrpa/core/timer.py index a2be4e0..92117e4 100644 --- a/fastrpa/core/timer.py +++ b/fastrpa/core/timer.py @@ -5,7 +5,6 @@ class Timer: - def __init__(self, webdriver: WebDriver): self.webdriver = webdriver diff --git a/fastrpa/exceptions.py b/fastrpa/exceptions.py index 298cc4c..c62c7ec 100644 --- a/fastrpa/exceptions.py +++ b/fastrpa/exceptions.py @@ -1,13 +1,12 @@ - class ElementNotFound(Exception): - message = "Element [{}] not found!" + message = 'Element [{}] not found!' def __init__(self, xpath: str) -> None: super().__init__(self.message.format(xpath)) class ElementNotFoundAfterTime(Exception): - message = "Element [{}] not found after {} seconds!" + message = 'Element [{}] not found after {} seconds!' def __init__(self, xpath: str, timeout: int) -> None: super().__init__(self.message.format(xpath, timeout)) diff --git a/fastrpa/factories.py b/fastrpa/factories.py index 258d2ee..c9bb72d 100644 --- a/fastrpa/factories.py +++ b/fastrpa/factories.py @@ -5,35 +5,48 @@ from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.remote.webelement import WebElement -from fastrpa.core.elements import ButtonElement, Element, FileInputElement, FormElement, InputElement, ListElement, SelectElement +from fastrpa.core.elements import ( + ButtonElement, + Element, + FileInputElement, + FormElement, + InputElement, + ListElement, + SelectElement, + TableElement, +) from fastrpa.exceptions import ElementNotFound, ElementNotFoundAfterTime from fastrpa.types import WebDriver class ElementFactory: - def __init__(self, webdriver: WebDriver): self.webdriver = webdriver def element_class(self, element: WebElement) -> Type[Element]: if element.tag_name in ['a', 'button']: return ButtonElement - + if element.tag_name in ['ol', 'ul']: return ListElement - - elif all([element.tag_name == 'input', element.get_attribute('type') == 'file']): + + elif all( + [element.tag_name == 'input', element.get_attribute('type') == 'file'] + ): return FileInputElement - + elif element.tag_name == 'input': return InputElement - + elif element.tag_name == 'select': return SelectElement - + elif element.tag_name == 'form': return FormElement - + + elif element.tag_name == 'table': + return TableElement + return Element def get_when_available(self, xpath: str, timeout: int = 15) -> Element: @@ -43,7 +56,7 @@ def get_when_available(self, xpath: str, timeout: int = 15) -> Element: ) element_class = self.element_class(selenium_element) return element_class(selenium_element, self.webdriver) - + except TimeoutException: raise ElementNotFoundAfterTime(xpath, timeout) @@ -52,6 +65,6 @@ def get(self, xpath: str) -> Element: selenium_element = self.webdriver.find_element(By.XPATH, xpath) element_class = self.element_class(selenium_element) return element_class(selenium_element, self.webdriver) - + except NoSuchElementException: raise ElementNotFound(xpath) diff --git a/fastrpa/settings.py b/fastrpa/settings.py index a58ca8a..0f9fb8d 100644 --- a/fastrpa/settings.py +++ b/fastrpa/settings.py @@ -1,4 +1,2 @@ - - VISIBILITY_TIMEOUT = 10 HIDDEN_TIMEOUT = 60 diff --git a/pyproject.toml b/pyproject.toml index a70f9e8..1e4c111 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,4 +21,8 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.ruff] -line-length = 88 \ No newline at end of file +line-length = 88 + +[tool.ruff.format] +quote-style = "single" +docstring-code-format = true From 78f0f9b0732a6364f33aac950a3cf6b1c80b0d50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Tue, 16 Jul 2024 20:29:43 -0300 Subject: [PATCH 03/78] fix - getting td and th for each row --- fastrpa/core/elements.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index adce72c..5c8f463 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -277,7 +277,7 @@ def rows(self) -> list[list[str | None]]: rows_content.append( [ cell.get_attribute('innerText') - for cell in element.find_elements(By.XPATH, './/td') + for cell in element.find_elements(By.XPATH, './/td | .//th') ] ) self._rows = rows_content From f00deb49950681b42c6e6eb636b96b04e91aef6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Thu, 18 Jul 2024 12:19:19 -0300 Subject: [PATCH 04/78] feat - table printing --- fastrpa/__init__.py | 18 ++++++++++++++ fastrpa/app.py | 51 ++++++++++++++++++++++++++++++++++++---- fastrpa/core/elements.py | 12 +++++++++- fastrpa/core/keyboard.py | 5 +++- fastrpa/exceptions.py | 7 ++++++ fastrpa/types.py | 7 +----- 6 files changed, 88 insertions(+), 12 deletions(-) diff --git a/fastrpa/__init__.py b/fastrpa/__init__.py index 3ca4cca..9696002 100644 --- a/fastrpa/__init__.py +++ b/fastrpa/__init__.py @@ -1,10 +1,28 @@ from fastrpa.app import FastRPA, Web +from fastrpa.core.elements import ( + Element, + InputElement, + FileInputElement, + SelectElement, + ListElement, + ButtonElement, + FormElement, + TableElement, +) from fastrpa.exceptions import ElementNotFound, ElementNotFoundAfterTime __all__ = ( 'FastRPA', 'Web', + 'Element', + 'InputElement', + 'FileInputElement', + 'SelectElement', + 'ListElement', + 'ButtonElement', + 'FormElement', + 'TableElement', 'ElementNotFound', 'ElementNotFoundAfterTime', ) diff --git a/fastrpa/app.py b/fastrpa/app.py index 54f1c93..13f8991 100644 --- a/fastrpa/app.py +++ b/fastrpa/app.py @@ -1,9 +1,20 @@ +from typing import Type, TypeVar from selenium.webdriver import Remote, ChromeOptions from fastrpa.commons import ( get_browser_options, ) +from fastrpa.exceptions import ElementNotCompatible from fastrpa.settings import VISIBILITY_TIMEOUT -from fastrpa.core.elements import Element +from fastrpa.core.elements import ( + Element, + InputElement, + FileInputElement, + ButtonElement, + FormElement, + ListElement, + TableElement, + SelectElement, +) from fastrpa.core.timer import Timer from fastrpa.core.keyboard import Keyboard @@ -11,6 +22,9 @@ from fastrpa.types import BrowserOptions, BrowserOptionsClass, WebDriver +SpecificElement = TypeVar('SpecificElement', bound=Element) + + class Web: def __init__( self, @@ -47,13 +61,42 @@ def timer(self) -> Timer: def reset(self): self.webdriver.get(self.starter_url) - def element(self, xpath: str, wait: bool = True) -> Element: + def has_content(self, value: str) -> bool: + return value in self.webdriver.page_source + + def element(self, xpath: str, wait: bool = True) -> Element | SpecificElement: if not wait: return self._element_factory.get(xpath) return self._element_factory.get_when_available(xpath, self.visibility_timeout) - def has_content(self, value: str) -> bool: - return value in self.webdriver.page_source + def _specific_element( + self, xpath: str, class_name: Type[SpecificElement], wait: bool = True + ) -> SpecificElement: + element = self.element(xpath, wait) + if not isinstance(element, class_name): + raise ElementNotCompatible(xpath, class_name) + return element + + def input(self, xpath: str, wait: bool = True) -> InputElement: + return self._specific_element(xpath, InputElement, wait) + + def file_input(self, xpath: str, wait: bool = True) -> FileInputElement: + return self._specific_element(xpath, FileInputElement, wait) + + def button(self, xpath: str, wait: bool = True) -> ButtonElement: + return self._specific_element(xpath, ButtonElement, wait) + + def form(self, xpath: str, wait: bool = True) -> FormElement: + return self._specific_element(xpath, FormElement, wait) + + def select(self, xpath: str, wait: bool = True) -> SelectElement: + return self._specific_element(xpath, SelectElement, wait) + + def list(self, xpath: str, wait: bool = True) -> ListElement: + return self._specific_element(xpath, ListElement, wait) + + def table(self, xpath: str, wait: bool = True) -> TableElement: + return self._specific_element(xpath, TableElement, wait) class FastRPA: diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index 5c8f463..6c50dfb 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -2,11 +2,14 @@ from typing import Any from selenium.webdriver import ActionChains from selenium.webdriver.support.ui import Select +from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.common.by import By +from rich.table import Table +from rich.console import Console from fastrpa.commons import get_file_path from fastrpa.dataclasses import Item, Option -from fastrpa.types import WebDriver, WebElement +from fastrpa.types import WebDriver class Element: @@ -300,3 +303,10 @@ def has_content(self, value: str) -> bool: for cell in self.element.find_elements('.//td') ] return value in cells_content + + def print(self): + console = Console() + rich_table = Table(*self.headers) + for row in self.rows: + rich_table.add_row(*row) + console.print(rich_table) diff --git a/fastrpa/core/keyboard.py b/fastrpa/core/keyboard.py index edd9ddc..aee21b5 100644 --- a/fastrpa/core/keyboard.py +++ b/fastrpa/core/keyboard.py @@ -1,4 +1,7 @@ -from fastrpa.types import WebDriver, Keys, ActionChains +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.common.action_chains import ActionChains + +from fastrpa.types import WebDriver class Keyboard: diff --git a/fastrpa/exceptions.py b/fastrpa/exceptions.py index c62c7ec..90a7ec7 100644 --- a/fastrpa/exceptions.py +++ b/fastrpa/exceptions.py @@ -10,3 +10,10 @@ class ElementNotFoundAfterTime(Exception): def __init__(self, xpath: str, timeout: int) -> None: super().__init__(self.message.format(xpath, timeout)) + + +class ElementNotCompatible(Exception): + message = 'Element [{}] is not compatible with {}!' + + def __init__(self, xpath: str, class_name: type) -> None: + super().__init__(self.message.format(xpath, class_name)) diff --git a/fastrpa/types.py b/fastrpa/types.py index 82bb3da..4dfe748 100644 --- a/fastrpa/types.py +++ b/fastrpa/types.py @@ -1,8 +1,5 @@ from typing import Type -from selenium.webdriver.remote.webelement import WebElement -from selenium.webdriver.common.keys import Keys from selenium.webdriver import ( - ActionChains, ChromeOptions, SafariOptions, FirefoxOptions, @@ -17,11 +14,9 @@ BrowserOptions = ChromeOptions | SafariOptions | FirefoxOptions BrowserOptionsClass = Type[ChromeOptions] | Type[SafariOptions] | Type[FirefoxOptions] + __all__ = ( - 'ActionChains', 'WebDriver', - 'WebElement', - 'Keys', 'BrowserOptions', 'BrowserOptionsClass', ) From ed86723b90b65c83379100a92e6797848ed9fcc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Thu, 18 Jul 2024 13:58:03 -0300 Subject: [PATCH 05/78] feat - screenshot abstraction --- fastrpa/app.py | 18 ++++++++++++------ fastrpa/commons.py | 28 +--------------------------- fastrpa/core/keyboard.py | 6 +++--- fastrpa/core/screenshot.py | 36 ++++++++++++++++++++++++++++++++++++ fastrpa/core/timer.py | 17 ++++++++++++----- 5 files changed, 64 insertions(+), 41 deletions(-) create mode 100644 fastrpa/core/screenshot.py diff --git a/fastrpa/app.py b/fastrpa/app.py index 13f8991..4bf7d43 100644 --- a/fastrpa/app.py +++ b/fastrpa/app.py @@ -3,6 +3,7 @@ from fastrpa.commons import ( get_browser_options, ) +from fastrpa.core.screenshot import Screenshot from fastrpa.exceptions import ElementNotCompatible from fastrpa.settings import VISIBILITY_TIMEOUT from fastrpa.core.elements import ( @@ -34,6 +35,7 @@ def __init__( ): self._keyboard: Keyboard | None = None self._timer: Timer | None = None + self._screenshot: Screenshot | None = None self.starter_url = url self.webdriver = webdriver self.webdriver.get(self.starter_url) @@ -46,17 +48,21 @@ def url(self) -> str: @property def keyboard(self) -> Keyboard: - if self._keyboard: - return self._keyboard - self._keyboard = Keyboard(self.webdriver) + if self._keyboard is None: + self._keyboard = Keyboard(self.webdriver) return self._keyboard @property def timer(self) -> Timer: - if self._timer: - return self._timer - self._timer = Timer(self.webdriver) + if self._timer is None: + self._timer = Timer(self.webdriver) return self._timer + + @property + def screenshot(self) -> Screenshot: + if self._screenshot is None: + self._screenshot = Screenshot(self.webdriver) + return self._screenshot def reset(self): self.webdriver.get(self.starter_url) diff --git a/fastrpa/commons.py b/fastrpa/commons.py index f5091f1..7804b5f 100644 --- a/fastrpa/commons.py +++ b/fastrpa/commons.py @@ -1,15 +1,10 @@ from selenium.webdriver import ChromeOptions -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions -from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.remote.webelement import WebElement import os import requests import mimetypes -from fastrpa.settings import HIDDEN_TIMEOUT -from fastrpa.types import BrowserOptions, BrowserOptionsClass, WebDriver +from fastrpa.types import BrowserOptions, BrowserOptionsClass def get_browser_options( @@ -20,27 +15,6 @@ def get_browser_options( instance.add_argument(opt) return instance - -def wait_until_element_is_hidden( - webdriver_or_parent_node: WebDriver | WebElement, - xpath: str, - timeout: int = HIDDEN_TIMEOUT, -): - WebDriverWait(webdriver_or_parent_node, timeout).until_not( - expected_conditions.element_to_be_clickable((By.XPATH, xpath)) - ) - - -def wait_until_element_is_present( - webdriver_or_parent_node: WebDriver | WebElement, - xpath: str, - timeout: int = HIDDEN_TIMEOUT, -): - WebDriverWait(webdriver_or_parent_node, timeout).until( - expected_conditions.presence_of_element_located((By.XPATH, xpath)) - ) - - def get_file_path(path: str) -> str: if os.path.isfile(path): return path diff --git a/fastrpa/core/keyboard.py b/fastrpa/core/keyboard.py index aee21b5..7e87f79 100644 --- a/fastrpa/core/keyboard.py +++ b/fastrpa/core/keyboard.py @@ -7,11 +7,11 @@ class Keyboard: def __init__(self, webdriver: WebDriver): self.webdriver = webdriver + self.actions = ActionChains(self.webdriver) def _press_key(self, key: str): - action = ActionChains(self.webdriver) - action.send_keys(key) - action.perform() + self.actions.send_keys(key) + self.actions.perform() def press_esc(self): self._press_key(Keys.ESCAPE) diff --git a/fastrpa/core/screenshot.py b/fastrpa/core/screenshot.py new file mode 100644 index 0000000..5d348dc --- /dev/null +++ b/fastrpa/core/screenshot.py @@ -0,0 +1,36 @@ +from datetime import datetime +from selenium.webdriver.common.by import By +from fastrpa.types import WebDriver + + +class Screenshot: + def __init__(self, webdriver: WebDriver): + self.webdriver = webdriver + + def _file_path(self, path: str | None = None) -> str: + if path is None: + path = f'{datetime.now().isoformat()}.png' + return path + + @property + def max_width(self) -> int: + return self.webdriver.execute_script('return document.body.parentNode.scrollWidth') + + @property + def max_height(self) -> int: + return self.webdriver.execute_script('return document.body.parentNode.scrollHeight') + + def viewport(self, path: str | None = None): + self.webdriver.save_screenshot(self._file_path(path)) + + def full_page(self, path: str | None = None): + starter_position = self.webdriver.get_window_position() + starter_size = self.webdriver.get_window_size() + + try: + self.webdriver.set_window_size(self.max_width, self.max_height) + self.webdriver.find_element(By.XPATH, '//body').screenshot(self._file_path(path)) + + finally: + self.webdriver.set_window_size(starter_size['width'], starter_size['height']) + self.webdriver.set_window_position(starter_position['x'], starter_position['y']) diff --git a/fastrpa/core/timer.py b/fastrpa/core/timer.py index 92117e4..d314867 100644 --- a/fastrpa/core/timer.py +++ b/fastrpa/core/timer.py @@ -1,6 +1,9 @@ from time import sleep -from fastrpa.commons import wait_until_element_is_hidden, wait_until_element_is_present +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions +from selenium.webdriver.support.ui import WebDriverWait + from fastrpa.types import WebDriver @@ -8,11 +11,15 @@ class Timer: def __init__(self, webdriver: WebDriver): self.webdriver = webdriver - def wait_until_hide(self, xpath: str): - wait_until_element_is_hidden(self.webdriver, xpath) + def wait_until_hide(self, xpath: str, timeout: int = 15): + WebDriverWait(self.webdriver, timeout).until_not( + expected_conditions.element_to_be_clickable((By.XPATH, xpath)) + ) - def wait_until_present(self, xpath: str): - wait_until_element_is_present(self.webdriver, xpath) + def wait_until_present(self, xpath: str, timeout: int = 15): + WebDriverWait(self.webdriver, timeout).until( + expected_conditions.presence_of_element_located((By.XPATH, xpath)) + ) def wait_seconds(self, seconds: int): sleep(seconds) From b3e23e71c55fa09df4bd89868af909e76349e8f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Thu, 18 Jul 2024 14:04:54 -0300 Subject: [PATCH 06/78] refact - simplify code --- fastrpa/app.py | 54 ++++++++++++-------------------------- fastrpa/commons.py | 1 + fastrpa/core/screenshot.py | 24 ++++++++++++----- fastrpa/core/timer.py | 8 +++--- 4 files changed, 39 insertions(+), 48 deletions(-) diff --git a/fastrpa/app.py b/fastrpa/app.py index 4bf7d43..22faaa3 100644 --- a/fastrpa/app.py +++ b/fastrpa/app.py @@ -31,39 +31,21 @@ def __init__( self, url: str, webdriver: WebDriver, - visibility_timeout: int, + visibility_timeout_seconds: int, ): - self._keyboard: Keyboard | None = None - self._timer: Timer | None = None - self._screenshot: Screenshot | None = None self.starter_url = url self.webdriver = webdriver self.webdriver.get(self.starter_url) + self.visibility_timeout_seconds = visibility_timeout_seconds + self.keyboard = Keyboard(self.webdriver) + self.timer = Timer(self.webdriver) + self.screenshot = Screenshot(self.webdriver) self._element_factory = ElementFactory(self.webdriver) - self.visibility_timeout = visibility_timeout @property def url(self) -> str: return self.webdriver.current_url - @property - def keyboard(self) -> Keyboard: - if self._keyboard is None: - self._keyboard = Keyboard(self.webdriver) - return self._keyboard - - @property - def timer(self) -> Timer: - if self._timer is None: - self._timer = Timer(self.webdriver) - return self._timer - - @property - def screenshot(self) -> Screenshot: - if self._screenshot is None: - self._screenshot = Screenshot(self.webdriver) - return self._screenshot - def reset(self): self.webdriver.get(self.starter_url) @@ -73,7 +55,9 @@ def has_content(self, value: str) -> bool: def element(self, xpath: str, wait: bool = True) -> Element | SpecificElement: if not wait: return self._element_factory.get(xpath) - return self._element_factory.get_when_available(xpath, self.visibility_timeout) + return self._element_factory.get_when_available( + xpath, self.visibility_timeout_seconds + ) def _specific_element( self, xpath: str, class_name: Type[SpecificElement], wait: bool = True @@ -113,36 +97,32 @@ def __init__( webdriver: WebDriver | None = None, options_class: BrowserOptionsClass = ChromeOptions, browser_arguments: list[str] | None = None, - visibility_timeout: int = VISIBILITY_TIMEOUT, + visibility_timeout_seconds: int = VISIBILITY_TIMEOUT, ): self._browser_options: BrowserOptions | None = None self._webdriver = webdriver self._options_class = options_class - self.visibility_timeout = visibility_timeout + self.visibility_timeout_seconds = visibility_timeout_seconds if browser_arguments: self.browser_arguments = browser_arguments @property def browser_options(self) -> BrowserOptions: - if self._browser_options: - return self._browser_options - - self._browser_options = get_browser_options( - options=self.browser_arguments, options_class=self._options_class - ) + if self._browser_options is None: + self._browser_options = get_browser_options( + options=self.browser_arguments, options_class=self._options_class + ) return self._browser_options @property def webdriver(self) -> WebDriver: - if self._webdriver: - return self._webdriver - - self._webdriver = Remote(options=self.browser_options) + if self._webdriver is None: + self._webdriver = Remote(options=self.browser_options) return self._webdriver def __del__(self): self.webdriver.quit() def browse(self, url: str) -> Web: - return Web(url, self.webdriver, self.visibility_timeout) + return Web(url, self.webdriver, self.visibility_timeout_seconds) diff --git a/fastrpa/commons.py b/fastrpa/commons.py index 7804b5f..1cbd0c2 100644 --- a/fastrpa/commons.py +++ b/fastrpa/commons.py @@ -15,6 +15,7 @@ def get_browser_options( instance.add_argument(opt) return instance + def get_file_path(path: str) -> str: if os.path.isfile(path): return path diff --git a/fastrpa/core/screenshot.py b/fastrpa/core/screenshot.py index 5d348dc..5c531db 100644 --- a/fastrpa/core/screenshot.py +++ b/fastrpa/core/screenshot.py @@ -14,11 +14,15 @@ def _file_path(self, path: str | None = None) -> str: @property def max_width(self) -> int: - return self.webdriver.execute_script('return document.body.parentNode.scrollWidth') - + return self.webdriver.execute_script( + 'return document.body.parentNode.scrollWidth' + ) + @property def max_height(self) -> int: - return self.webdriver.execute_script('return document.body.parentNode.scrollHeight') + return self.webdriver.execute_script( + 'return document.body.parentNode.scrollHeight' + ) def viewport(self, path: str | None = None): self.webdriver.save_screenshot(self._file_path(path)) @@ -29,8 +33,14 @@ def full_page(self, path: str | None = None): try: self.webdriver.set_window_size(self.max_width, self.max_height) - self.webdriver.find_element(By.XPATH, '//body').screenshot(self._file_path(path)) - + self.webdriver.find_element(By.XPATH, '//body').screenshot( + self._file_path(path) + ) + finally: - self.webdriver.set_window_size(starter_size['width'], starter_size['height']) - self.webdriver.set_window_position(starter_position['x'], starter_position['y']) + self.webdriver.set_window_size( + starter_size['width'], starter_size['height'] + ) + self.webdriver.set_window_position( + starter_position['x'], starter_position['y'] + ) diff --git a/fastrpa/core/timer.py b/fastrpa/core/timer.py index d314867..c7feb9b 100644 --- a/fastrpa/core/timer.py +++ b/fastrpa/core/timer.py @@ -11,13 +11,13 @@ class Timer: def __init__(self, webdriver: WebDriver): self.webdriver = webdriver - def wait_until_hide(self, xpath: str, timeout: int = 15): - WebDriverWait(self.webdriver, timeout).until_not( + def wait_until_hide(self, xpath: str, timeout_seconds: int = 15): + WebDriverWait(self.webdriver, timeout_seconds).until_not( expected_conditions.element_to_be_clickable((By.XPATH, xpath)) ) - def wait_until_present(self, xpath: str, timeout: int = 15): - WebDriverWait(self.webdriver, timeout).until( + def wait_until_present(self, xpath: str, timeout_seconds: int = 15): + WebDriverWait(self.webdriver, timeout_seconds).until( expected_conditions.presence_of_element_located((By.XPATH, xpath)) ) From d91276848a2386622426ea208cf9074ef4d11b78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Thu, 18 Jul 2024 17:57:12 -0300 Subject: [PATCH 07/78] fix - getting complete page screenshot --- fastrpa/core/screenshot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastrpa/core/screenshot.py b/fastrpa/core/screenshot.py index 5c531db..a294ae3 100644 --- a/fastrpa/core/screenshot.py +++ b/fastrpa/core/screenshot.py @@ -15,13 +15,13 @@ def _file_path(self, path: str | None = None) -> str: @property def max_width(self) -> int: return self.webdriver.execute_script( - 'return document.body.parentNode.scrollWidth' + 'return document.documentElement.scrollWidth' ) @property def max_height(self) -> int: return self.webdriver.execute_script( - 'return document.body.parentNode.scrollHeight' + 'return document.documentElement.scrollHeight + (window.innerHeight * 0.2)' ) def viewport(self, path: str | None = None): From e83ef2df0aa7af07f687558a4187d9c0678b07a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Fri, 19 Jul 2024 12:54:16 -0300 Subject: [PATCH 08/78] feat - cookies abstraction --- fastrpa/app.py | 2 ++ fastrpa/core/cookies.py | 50 ++++++++++++++++++++++++++++++++++++++++ fastrpa/core/elements.py | 3 +-- fastrpa/core/timer.py | 2 +- fastrpa/dataclasses.py | 35 ++++++++++++++++++++++++++++ fastrpa/exceptions.py | 7 ++++++ 6 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 fastrpa/core/cookies.py diff --git a/fastrpa/app.py b/fastrpa/app.py index 22faaa3..0b33d12 100644 --- a/fastrpa/app.py +++ b/fastrpa/app.py @@ -3,6 +3,7 @@ from fastrpa.commons import ( get_browser_options, ) +from fastrpa.core.cookies import Cookies from fastrpa.core.screenshot import Screenshot from fastrpa.exceptions import ElementNotCompatible from fastrpa.settings import VISIBILITY_TIMEOUT @@ -40,6 +41,7 @@ def __init__( self.keyboard = Keyboard(self.webdriver) self.timer = Timer(self.webdriver) self.screenshot = Screenshot(self.webdriver) + self.cookies = Cookies(self.webdriver) self._element_factory = ElementFactory(self.webdriver) @property diff --git a/fastrpa/core/cookies.py b/fastrpa/core/cookies.py new file mode 100644 index 0000000..7e397b3 --- /dev/null +++ b/fastrpa/core/cookies.py @@ -0,0 +1,50 @@ +from typing import Any +from urllib.parse import urlparse + +from fastrpa.exceptions import CookieNotAdded +from fastrpa.types import WebDriver +from fastrpa.dataclasses import Cookie + + +class Cookies: + def __init__(self, webdriver: WebDriver): + self.webdriver = webdriver + + @property + def list_names(self) -> list[str]: + cookies = [] + for cookie in self.webdriver.get_cookies(): + cookies.append(cookie['name']) + return cookies + + @property + def list(self) -> list[Cookie]: + cookies = [] + for cookie in self.webdriver.get_cookies(): + cookies.append(Cookie.from_selenium(cookie)) + return cookies + + def get(self, name: str) -> Cookie | None: + if cookie := self.webdriver.get_cookie(name): + return Cookie.from_selenium(cookie) + return None + + def add(self, name: str, value: str, secure: bool = False) -> Cookie: + domain = urlparse(self.webdriver.current_url).netloc + cookie = Cookie(name, value, domain, secure=secure) + self.webdriver.add_cookie(cookie.to_selenium()) + if added_cookie := self.webdriver.get_cookie(name): + return Cookie.from_selenium(added_cookie) + raise CookieNotAdded(name) + + def check(self, name: str, value: str) -> bool: + if cookie := self.get(name): + return cookie.value == value + return False + + def has_cookie(self, name: str) -> bool: + return name in self.list_names + + def __contains__(self, key: Any) -> bool: + return key in self.list_names + \ No newline at end of file diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index 6c50dfb..f5f4a37 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -305,8 +305,7 @@ def has_content(self, value: str) -> bool: return value in cells_content def print(self): - console = Console() rich_table = Table(*self.headers) for row in self.rows: rich_table.add_row(*row) - console.print(rich_table) + Console().print(rich_table) diff --git a/fastrpa/core/timer.py b/fastrpa/core/timer.py index c7feb9b..f6915b2 100644 --- a/fastrpa/core/timer.py +++ b/fastrpa/core/timer.py @@ -18,7 +18,7 @@ def wait_until_hide(self, xpath: str, timeout_seconds: int = 15): def wait_until_present(self, xpath: str, timeout_seconds: int = 15): WebDriverWait(self.webdriver, timeout_seconds).until( - expected_conditions.presence_of_element_located((By.XPATH, xpath)) + expected_conditions.element_to_be_clickable((By.XPATH, xpath)) ) def wait_seconds(self, seconds: int): diff --git a/fastrpa/dataclasses.py b/fastrpa/dataclasses.py index 2959540..146bfac 100644 --- a/fastrpa/dataclasses.py +++ b/fastrpa/dataclasses.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Any, Literal @dataclass @@ -11,3 +12,37 @@ class Option: class Item: id: str | None label: str | None + + +@dataclass +class Cookie: + name: str + value: str + domain: str + path: str = '/' + secure: bool = False + http_only: bool = True + same_site: Literal["Strict", "Lax", "None"] = "None" + + @staticmethod + def from_selenium(content: dict[str, Any]) -> 'Cookie': + return Cookie( + name=content['name'], + value=content['value'], + domain=content['domain'], + path=content['path'], + secure=content['secure'], + http_only=content['httpOnly'], + same_site=content['sameSite'], + ) + + def to_selenium(self) -> dict[str, Any]: + return { + 'name': self.name, + 'value': self.value, + 'domain': self.domain, + 'path': self.path, + 'secure': self.secure, + 'httpOnly': self.http_only, + 'sameSite': self.same_site, + } diff --git a/fastrpa/exceptions.py b/fastrpa/exceptions.py index 90a7ec7..185c8cf 100644 --- a/fastrpa/exceptions.py +++ b/fastrpa/exceptions.py @@ -17,3 +17,10 @@ class ElementNotCompatible(Exception): def __init__(self, xpath: str, class_name: type) -> None: super().__init__(self.message.format(xpath, class_name)) + + +class CookieNotAdded(Exception): + message = 'The cookie [{}] was not added!' + + def __init__(self, cookie_name: str) -> None: + super().__init__(self.message.format(cookie_name)) From cb7b3c1761df7a9b082981bfc178861c4ddbaff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Fri, 19 Jul 2024 12:59:30 -0300 Subject: [PATCH 09/78] refact - source and tag --- fastrpa/core/elements.py | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index f5f4a37..078c605 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -20,8 +20,8 @@ class Element: _is_visible: bool | None = None _actions: ActionChains | None = None - def __init__(self, element: WebElement, webdriver: WebDriver) -> None: - self.element = element + def __init__(self, source: WebElement, webdriver: WebDriver) -> None: + self.source = source self.webdriver = webdriver @property @@ -33,27 +33,27 @@ def actions(self) -> ActionChains: @property def tag(self) -> str: if self._tag is None: - self._tag = self.element.tag_name + self._tag = self.source.tag_name return self._tag @property def id(self) -> str | None: if self._id is None: - self._id = self.element.get_attribute('id') + self._id = self.source.get_attribute('id') return self._id @property def css_class(self) -> str | None: if self._css_class is None: - self._css_class = self.element.get_attribute('class') + self._css_class = self.source.get_attribute('class') return self._css_class @property def text(self) -> str | None: if self._text is None: - if self.element.text: - self._text = self.element.text - elif value := self.element.get_attribute('value'): + if self.source.text: + self._text = self.source.text + elif value := self.source.get_attribute('value'): self._text = value else: self._text = None @@ -62,7 +62,7 @@ def text(self) -> str | None: @property def is_visible(self) -> bool: if self._is_visible is None: - self._is_visible = self.element.is_displayed() + self._is_visible = self.source.is_displayed() return self._is_visible def focus(self): @@ -71,25 +71,25 @@ def focus(self): self.actions.perform() def check(self, attribute: str, value: str) -> bool: - return self.element.get_attribute(attribute) == value + return self.source.get_attribute(attribute) == value class InputElement(Element): def clear(self): - self.element.clear() + self.source.clear() def fill(self, value: str): - self.element.send_keys(value) + self.source.send_keys(value) def fill_slowly(self, value: str, delay: float = 0.3): for key in value: - self.element.send_keys(key) + self.source.send_keys(key) sleep(delay) class FileInputElement(Element): def attach_file(self, path: str): - self.element.send_keys(get_file_path(path)) + self.source.send_keys(get_file_path(path)) class SelectElement(Element): @@ -159,13 +159,13 @@ class ListElement(Element): @property def items_elements(self) -> list[WebElement]: if self._items_elements is None: - self._items_elements = self.element.find_elements(By.XPATH, './/li') + self._items_elements = self.source.find_elements(By.XPATH, './/li') return self._items_elements @property def is_ordered(self) -> bool: if self._is_ordered is None: - self._is_ordered = self.element.tag_name == 'ol' + self._is_ordered = self.tag == 'ol' return self._is_ordered @property @@ -236,7 +236,7 @@ def double_click(self): class FormElement(Element): def submit(self, button: ButtonElement | None = None): if not button: - self.element.submit() + self.source.submit() else: button.click() @@ -250,7 +250,7 @@ class TableElement(Element): @property def headers_elements(self) -> list[WebElement]: if self._headers_elements is None: - first_row = self.element.find_element(By.XPATH, './/tr') + first_row = self.source.find_element(By.XPATH, './/tr') self._headers_elements = first_row.find_elements(By.XPATH, './/th') return self._headers_elements @@ -266,7 +266,7 @@ def headers(self) -> list[str | None]: @property def rows_elements(self) -> list[WebElement]: if self._rows_elements is None: - rows = self.element.find_elements(By.XPATH, './/tr') + rows = self.source.find_elements(By.XPATH, './/tr') if self.headers: del rows[0] self._rows_elements = rows @@ -300,7 +300,7 @@ def column_values( def has_content(self, value: str) -> bool: cells_content = [ cell.get_attribute('innerText') - for cell in self.element.find_elements('.//td') + for cell in self.source.find_elements('.//td') ] return value in cells_content From 4499f87c0c9bd251319fd1313a674359030164b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Fri, 19 Jul 2024 13:07:29 -0300 Subject: [PATCH 10/78] feat - adding many elements reading --- fastrpa/app.py | 11 +++++++---- fastrpa/core/elements.py | 14 +++++++------- fastrpa/exceptions.py | 2 +- fastrpa/{factories.py => factory.py} | 13 +++++++++++++ 4 files changed, 28 insertions(+), 12 deletions(-) rename fastrpa/{factories.py => factory.py} (82%) diff --git a/fastrpa/app.py b/fastrpa/app.py index 0b33d12..48dbc79 100644 --- a/fastrpa/app.py +++ b/fastrpa/app.py @@ -20,7 +20,7 @@ from fastrpa.core.timer import Timer from fastrpa.core.keyboard import Keyboard -from fastrpa.factories import ElementFactory +from fastrpa.factory import ElementFactory from fastrpa.types import BrowserOptions, BrowserOptionsClass, WebDriver @@ -42,7 +42,7 @@ def __init__( self.timer = Timer(self.webdriver) self.screenshot = Screenshot(self.webdriver) self.cookies = Cookies(self.webdriver) - self._element_factory = ElementFactory(self.webdriver) + self.factory = ElementFactory(self.webdriver) @property def url(self) -> str: @@ -56,10 +56,13 @@ def has_content(self, value: str) -> bool: def element(self, xpath: str, wait: bool = True) -> Element | SpecificElement: if not wait: - return self._element_factory.get(xpath) - return self._element_factory.get_when_available( + return self.factory.get(xpath) + return self.factory.get_when_available( xpath, self.visibility_timeout_seconds ) + + def elements(self, xpath: str) -> list[Element | SpecificElement]: + return self.factory.get_many(xpath) def _specific_element( self, xpath: str, class_name: Type[SpecificElement], wait: bool = True diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index 078c605..287ad30 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -66,8 +66,8 @@ def is_visible(self) -> bool: return self._is_visible def focus(self): - self.actions.scroll_to_element(self.element) - self.actions.move_to_element(self.element) + self.actions.scroll_to_element(self.source) + self.actions.move_to_element(self.source) self.actions.perform() def check(self, attribute: str, value: str) -> bool: @@ -101,7 +101,7 @@ class SelectElement(Element): @property def select_element(self) -> Select: if not self._select_element: - self._select_element = Select(self.element) + self._select_element = Select(self.source) return self._select_element @property @@ -223,13 +223,13 @@ def __contains__(self, key: Any) -> bool: class ButtonElement(Element): def click(self): - self.actions.move_to_element(self.element) - self.actions.click(self.element) + self.actions.move_to_element(self.source) + self.actions.click(self.source) self.actions.perform() def double_click(self): - self.actions.move_to_element(self.element) - self.actions.double_click(self.element) + self.actions.move_to_element(self.source) + self.actions.double_click(self.source) self.actions.perform() diff --git a/fastrpa/exceptions.py b/fastrpa/exceptions.py index 185c8cf..99d84a9 100644 --- a/fastrpa/exceptions.py +++ b/fastrpa/exceptions.py @@ -1,5 +1,5 @@ class ElementNotFound(Exception): - message = 'Element [{}] not found!' + message = 'No one element [{}] was found!' def __init__(self, xpath: str) -> None: super().__init__(self.message.format(xpath)) diff --git a/fastrpa/factories.py b/fastrpa/factory.py similarity index 82% rename from fastrpa/factories.py rename to fastrpa/factory.py index c9bb72d..8c70dbd 100644 --- a/fastrpa/factories.py +++ b/fastrpa/factory.py @@ -68,3 +68,16 @@ def get(self, xpath: str) -> Element: except NoSuchElementException: raise ElementNotFound(xpath) + + def get_many(self, xpath: str) -> list[Element]: + elements_to_return = [] + + try: + for selenium_element in self.webdriver.find_elements(By.XPATH, xpath): + element_class = self.element_class(selenium_element) + elements_to_return.append(element_class(selenium_element, self.webdriver)) + + return elements_to_return + + except NoSuchElementException: + raise ElementNotFound(xpath) From 4ac754d2fc7adc8be2ce476f6bb17f8a64e24bb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Fri, 19 Jul 2024 13:18:35 -0300 Subject: [PATCH 11/78] feat - link methods in button element --- fastrpa/app.py | 11 +++++++---- fastrpa/commons.py | 7 ++++++- fastrpa/core/cookies.py | 15 +++++++-------- fastrpa/core/elements.py | 21 +++++++++++++++++---- fastrpa/dataclasses.py | 4 ++-- fastrpa/factory.py | 4 +++- 6 files changed, 42 insertions(+), 20 deletions(-) diff --git a/fastrpa/app.py b/fastrpa/app.py index 48dbc79..ad253bb 100644 --- a/fastrpa/app.py +++ b/fastrpa/app.py @@ -2,6 +2,7 @@ from selenium.webdriver import Remote, ChromeOptions from fastrpa.commons import ( get_browser_options, + get_domain, ) from fastrpa.core.cookies import Cookies from fastrpa.core.screenshot import Screenshot @@ -48,6 +49,10 @@ def __init__( def url(self) -> str: return self.webdriver.current_url + @property + def domain(self) -> str: + return get_domain(self.webdriver) + def reset(self): self.webdriver.get(self.starter_url) @@ -57,10 +62,8 @@ def has_content(self, value: str) -> bool: def element(self, xpath: str, wait: bool = True) -> Element | SpecificElement: if not wait: return self.factory.get(xpath) - return self.factory.get_when_available( - xpath, self.visibility_timeout_seconds - ) - + return self.factory.get_when_available(xpath, self.visibility_timeout_seconds) + def elements(self, xpath: str) -> list[Element | SpecificElement]: return self.factory.get_many(xpath) diff --git a/fastrpa/commons.py b/fastrpa/commons.py index 1cbd0c2..80160fc 100644 --- a/fastrpa/commons.py +++ b/fastrpa/commons.py @@ -1,10 +1,11 @@ +from urllib.parse import urlparse from selenium.webdriver import ChromeOptions import os import requests import mimetypes -from fastrpa.types import BrowserOptions, BrowserOptionsClass +from fastrpa.types import BrowserOptions, BrowserOptionsClass, WebDriver def get_browser_options( @@ -29,3 +30,7 @@ def get_file_path(path: str) -> str: file.write(file_response.content) return download_path + + +def get_domain(webdriver: WebDriver) -> str: + return urlparse(webdriver.current_url).netloc diff --git a/fastrpa/core/cookies.py b/fastrpa/core/cookies.py index 7e397b3..c08a7ee 100644 --- a/fastrpa/core/cookies.py +++ b/fastrpa/core/cookies.py @@ -1,6 +1,6 @@ from typing import Any -from urllib.parse import urlparse +from fastrpa.commons import get_domain from fastrpa.exceptions import CookieNotAdded from fastrpa.types import WebDriver from fastrpa.dataclasses import Cookie @@ -16,35 +16,34 @@ def list_names(self) -> list[str]: for cookie in self.webdriver.get_cookies(): cookies.append(cookie['name']) return cookies - + @property def list(self) -> list[Cookie]: cookies = [] for cookie in self.webdriver.get_cookies(): cookies.append(Cookie.from_selenium(cookie)) return cookies - + def get(self, name: str) -> Cookie | None: if cookie := self.webdriver.get_cookie(name): return Cookie.from_selenium(cookie) return None def add(self, name: str, value: str, secure: bool = False) -> Cookie: - domain = urlparse(self.webdriver.current_url).netloc + domain = get_domain(self.webdriver) cookie = Cookie(name, value, domain, secure=secure) self.webdriver.add_cookie(cookie.to_selenium()) if added_cookie := self.webdriver.get_cookie(name): return Cookie.from_selenium(added_cookie) raise CookieNotAdded(name) - + def check(self, name: str, value: str) -> bool: if cookie := self.get(name): return cookie.value == value return False - + def has_cookie(self, name: str) -> bool: return name in self.list_names - + def __contains__(self, key: Any) -> bool: return key in self.list_names - \ No newline at end of file diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index 287ad30..c2691f7 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -24,6 +24,9 @@ def __init__(self, source: WebElement, webdriver: WebDriver) -> None: self.source = source self.webdriver = webdriver + def attribute(self, name: str) -> str | None: + return self.source.get_attribute(name) + @property def actions(self) -> ActionChains: if self._actions is None: @@ -39,13 +42,13 @@ def tag(self) -> str: @property def id(self) -> str | None: if self._id is None: - self._id = self.source.get_attribute('id') + self._id = self.attribute('id') return self._id @property def css_class(self) -> str | None: if self._css_class is None: - self._css_class = self.source.get_attribute('class') + self._css_class = self.attribute('class') return self._css_class @property @@ -53,7 +56,7 @@ def text(self) -> str | None: if self._text is None: if self.source.text: self._text = self.source.text - elif value := self.source.get_attribute('value'): + elif value := self.attribute('value'): self._text = value else: self._text = None @@ -71,7 +74,7 @@ def focus(self): self.actions.perform() def check(self, attribute: str, value: str) -> bool: - return self.source.get_attribute(attribute) == value + return self.attribute(attribute) == value class InputElement(Element): @@ -222,6 +225,16 @@ def __contains__(self, key: Any) -> bool: class ButtonElement(Element): + @property + def is_link(self) -> bool: + return self.tag == 'a' + + @property + def reference(self) -> str | None: + if self.is_link: + return self.attribute('href') + return None + def click(self): self.actions.move_to_element(self.source) self.actions.click(self.source) diff --git a/fastrpa/dataclasses.py b/fastrpa/dataclasses.py index 146bfac..dc1b48e 100644 --- a/fastrpa/dataclasses.py +++ b/fastrpa/dataclasses.py @@ -22,7 +22,7 @@ class Cookie: path: str = '/' secure: bool = False http_only: bool = True - same_site: Literal["Strict", "Lax", "None"] = "None" + same_site: Literal['Strict', 'Lax', 'None'] = 'None' @staticmethod def from_selenium(content: dict[str, Any]) -> 'Cookie': @@ -35,7 +35,7 @@ def from_selenium(content: dict[str, Any]) -> 'Cookie': http_only=content['httpOnly'], same_site=content['sameSite'], ) - + def to_selenium(self) -> dict[str, Any]: return { 'name': self.name, diff --git a/fastrpa/factory.py b/fastrpa/factory.py index 8c70dbd..f082c22 100644 --- a/fastrpa/factory.py +++ b/fastrpa/factory.py @@ -75,7 +75,9 @@ def get_many(self, xpath: str) -> list[Element]: try: for selenium_element in self.webdriver.find_elements(By.XPATH, xpath): element_class = self.element_class(selenium_element) - elements_to_return.append(element_class(selenium_element, self.webdriver)) + elements_to_return.append( + element_class(selenium_element, self.webdriver) + ) return elements_to_return From 38fc7023243430c7c3f31636cca531496ca7cfb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Fri, 19 Jul 2024 15:15:17 -0300 Subject: [PATCH 12/78] feat - starter new readme content --- README.md | 223 +++++++++++++++++++++++++++++++-------- fastrpa/core/keyboard.py | 6 +- 2 files changed, 180 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 4e2bafe..d05685a 100644 --- a/README.md +++ b/README.md @@ -2,23 +2,28 @@ A simple to use abstraction over Selenium. -> [!WARNING] -> This is a beta project, and is still in development. Don't use it in production, because it can raise unknown errors. - -## Core concepts - -- Based on Selenium -- XPath-oriented -- Easy to use - -## Next steps - -- [x] Forms abstraction -- [ ] Tables abstraction -- [ ] Unit tests -- [ ] Documentation - -## Before use it +## How to use + +- [Configure Selenium integration](#configure-selenium-integration) +- [The FastRPA instance](#the-fastrpa-instance) + - [Interacting with the current page](#interacting-with-the-current-page) + - [Pressing keys](#pressing-keys) + - [Waiting for events](#waiting-for-events) + - [Managing cookies](#managing-cookies) + - [Take screenshots and prints](#take-screenshots-and-prints) + - [Manage and navigate through opened tabs](#manage-and-navigate-through-opened-tabs) + - [Running javascript](#running-javascript) + - [Interacting with the page elements](#interacting-with-the-page-elements) + - [Inputs](#inputs) + - [File inputs](#file-inputs) + - [Selects](#selects) + - [Lists](#lists) + - [Buttons and links](#buttons-and-links) + - [Tables](#tables) + - [Medias](#medias) + + +### Configure Selenium integration FastRPA needs a webdriver to work. It can be local, or remote. It's recommended to always use remote sessions. @@ -46,59 +51,185 @@ webdriver = Firefox(options) app = FastRPA(webdriver) ``` -## Examples of use +### The FastRPA instance -### Fill and submit a simple text form +This is just a configuration object. You will need it just to start your web navegation. ```python from fastrpa import FastRPA app = FastRPA() +web = app.browse('https:...') +type(web) +<<< fastrpa.app.Web +``` -form_page = app.browse('http://...') +### Interacting with the current page -my_form = form_page.form('//form[@id="login"]') -my_form.fill('//input[@id="username"]', 'user') -my_form.fill('//input[@id="password"]', 'pass') +Once you have a `Web` object, you are able to browse on the web. The `Web` class is a abstraction of main browser and user functions. -# To just submit the form -my_form.submit() +It includes, managing: -# To submit by clicking in a button -my_form.submit('//button[@id="submit"]') +- `keyboard`, to send key pressing events on the current page +- `timer`, to wait for some events on the current page +- `cookies`, to manage cookies on the current page +- `screenshot`, to download screenshots and prints from the current page +- `tabs`, to manage and navigate through the current opened tabs +- `console`, to run javascript on the current page +You can access these abstractions by calling it from the `Web` object. + +```python +web.keyboard +>>> + +web.timer +>>> + +web.cookies +>>> + +web.screenshot +>>> + +web.tabs +>>> + +web.console +>>> ``` -### Attach a file in a form +#### Pressing keys + +You can send simple pressing key events to the current page, by using the methods below. + +- `Web.keyboard.esc()` +- `Web.keyboard.tab()` +- `Web.keyboard.enter()` + +#### Waiting for events + +You can wait some time before or after execute some action with the automation. This method is just a simple proxy for `time.sleep`, to remove the need of more one import. + +- `Web.timer.wait_seconds(seconds)` + +You can also wait for element visibility. By default the timeout is 15 seconds, but you can also specify it. ```python -# You can easily attach files from the web -my_form.attach_file('//input[@id="photo"]', 'https://website.com/mypic.png') +app = FastRPA() +web = app.browse('https:...') + +# Wait a maximum of 15 seconds until a button is present +web.timer.wait_until_present('//button[@id="myBtn"]') + +# Wait a maximum of custom seconds until a button is present +web.timer.wait_until_present('//button[@id="myBtn"]', 30) -# Or just local files, if you prefer -my_form.attach_file('//input[@id="photo"]', '/home/user/mypic.png') +# Wait a maximum of 15 seconds until a button is hide +web.timer.wait_until_hide('//button[@id="myBtn"]') + +# Wait a maximum of custom seconds until a button is hide +web.timer.wait_until_hide('//button[@id="myBtn"]', 30) ``` -### Read the available options in a select +#### Managing cookies + +Follow the examples below to manage cookies on the current domain. ```python -my_form.get_select_options('//select[@id="occupation"]') - -[ - Option(value='1', label='Actor'), - Option(value='2', label='Developer'), - Option(value='3', label='Doctor'), - Option(value='4', label='Professor'), - ... +app = FastRPA() +web = app.browse('https:...') + +# Get the list of cookies on the current domain +web.cookies.list +>>> [Cookie(...), Cookie(...)] + +# Get the list of names from the cookies on the current domain +web.cookies.list_names +>>> ['JSESSIONID', '_ga', ...] + +# Check if a cookie exists on the current domain +'my_cookie' in web.cookies +>>> True + +# Check if a cookie stores some value +web.cookies.check('my_cookie', 'value') +>>> False + +# Get a cookie on the current domain +web.cookies.get('my_cookie') +>>> Cookie(name='...', value='...', domain='...', path='/', secure=True, http_only=True, same_site='Strict') + +# Try to get a cookie that does not exist the current domain +web.cookies.get('my_cookie') +>>> None + +# Add a new cookie on the current domain +web.cookies.add('my_cookie', 'value', secure=False) +>>> Cookie(name='my_cookie', value='value', domain='...', path='/', secure=False, http_only=True, same_site='Strict') ``` -### Select an option in a select +#### Take screenshots and prints + +By default, all screenshot methods save the files in the current active directory. ```python -# You can just select by the text label -my_form.select_option('//select[@id="occupation"]', 'Developer') +app = FastRPA() +web = app.browse('https:...') + +# Take a screenshot just containing the current viewport size +web.screenshot.viewport() -# Or just by the value, if you prefer -my_form.select_option('//select[@id="occupation"]', value='2') +# Take a screenshot just containing the current viewport size, and save the file in the specified path +web.screenshot.viewport('/my/screenshot/path.png') +# Take a screenshot containing the complete page +web.screenshot.full_page() + +# Take a screenshot containing the complete page, and save the file in the specified path +web.screenshot.full_page('/my/screenshot/path.png') ``` + +#### Manage and navigate through opened tabs + +Comming soon... + +#### Running javascript + +Comming soon... + +### Interacting with the page elements + +Need to write. + +#### Inputs + +Need to write. + +#### File inputs + +Need to write. + +#### Selects + +Need to write. + +#### Lists + +Need to write. + +#### Buttons and links + +Need to write. + +#### Forms + +Need to write. + +#### Tables + +Need to write. + +#### Medias + +Need to write. diff --git a/fastrpa/core/keyboard.py b/fastrpa/core/keyboard.py index 7e87f79..fa37353 100644 --- a/fastrpa/core/keyboard.py +++ b/fastrpa/core/keyboard.py @@ -13,11 +13,11 @@ def _press_key(self, key: str): self.actions.send_keys(key) self.actions.perform() - def press_esc(self): + def esc(self): self._press_key(Keys.ESCAPE) - def press_tab(self): + def tab(self): self._press_key(Keys.TAB) - def press_enter(self): + def enter(self): self._press_key(Keys.ENTER) From adeb279ddc40fa97582ab34c1c7f13ceaafa26d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Fri, 19 Jul 2024 15:17:34 -0300 Subject: [PATCH 13/78] fix - better readme formatting --- README.md | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index d05685a..ec434ca 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A simple to use abstraction over Selenium. -## How to use +### How to use - [Configure Selenium integration](#configure-selenium-integration) - [The FastRPA instance](#the-fastrpa-instance) @@ -23,7 +23,7 @@ A simple to use abstraction over Selenium. - [Medias](#medias) -### Configure Selenium integration +## Configure Selenium integration FastRPA needs a webdriver to work. It can be local, or remote. It's recommended to always use remote sessions. @@ -51,7 +51,7 @@ webdriver = Firefox(options) app = FastRPA(webdriver) ``` -### The FastRPA instance +## The FastRPA instance This is just a configuration object. You will need it just to start your web navegation. @@ -64,7 +64,7 @@ type(web) <<< fastrpa.app.Web ``` -### Interacting with the current page +## Interacting with the current page Once you have a `Web` object, you are able to browse on the web. The `Web` class is a abstraction of main browser and user functions. @@ -99,7 +99,7 @@ web.console >>> ``` -#### Pressing keys +### Pressing keys You can send simple pressing key events to the current page, by using the methods below. @@ -107,7 +107,7 @@ You can send simple pressing key events to the current page, by using the method - `Web.keyboard.tab()` - `Web.keyboard.enter()` -#### Waiting for events +### Waiting for events You can wait some time before or after execute some action with the automation. This method is just a simple proxy for `time.sleep`, to remove the need of more one import. @@ -132,7 +132,7 @@ web.timer.wait_until_hide('//button[@id="myBtn"]') web.timer.wait_until_hide('//button[@id="myBtn"]', 30) ``` -#### Managing cookies +### Managing cookies Follow the examples below to manage cookies on the current domain. @@ -169,7 +169,7 @@ web.cookies.add('my_cookie', 'value', secure=False) >>> Cookie(name='my_cookie', value='value', domain='...', path='/', secure=False, http_only=True, same_site='Strict') ``` -#### Take screenshots and prints +### Take screenshots and prints By default, all screenshot methods save the files in the current active directory. @@ -190,46 +190,46 @@ web.screenshot.full_page() web.screenshot.full_page('/my/screenshot/path.png') ``` -#### Manage and navigate through opened tabs +### Manage and navigate through opened tabs Comming soon... -#### Running javascript +### Running javascript Comming soon... -### Interacting with the page elements +## Interacting with the page elements Need to write. -#### Inputs +### Inputs Need to write. -#### File inputs +### File inputs Need to write. -#### Selects +### Selects Need to write. -#### Lists +### Lists Need to write. -#### Buttons and links +### Buttons and links Need to write. -#### Forms +### Forms Need to write. -#### Tables +### Tables Need to write. -#### Medias +### Medias Need to write. From 207f23609cfeb34edf4c4b688d0cc892f42ec34b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Fri, 19 Jul 2024 15:23:29 -0300 Subject: [PATCH 14/78] feat - screenshot examples --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index ec434ca..a963563 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,12 @@ web.screenshot.full_page() web.screenshot.full_page('/my/screenshot/path.png') ``` +You can see examples of both screenshot methods below. + +| Viewport screenshot | Full page screenshot | +|-|-| +| ![image](https://github.com/user-attachments/assets/ec5abfe0-5858-450a-a938-9fff58f89b86) | ![image](https://github.com/user-attachments/assets/606b73d7-52cb-4477-a328-89ba5b1563d1) | + ### Manage and navigate through opened tabs Comming soon... From 1e3cfee533228bb46e6b31500528a7b09aef85c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Fri, 19 Jul 2024 15:40:03 -0300 Subject: [PATCH 15/78] fix - sumary identation --- README.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index a963563..2cc36ec 100644 --- a/README.md +++ b/README.md @@ -2,25 +2,25 @@ A simple to use abstraction over Selenium. -### How to use +### Sumary - [Configure Selenium integration](#configure-selenium-integration) - [The FastRPA instance](#the-fastrpa-instance) - - [Interacting with the current page](#interacting-with-the-current-page) - - [Pressing keys](#pressing-keys) - - [Waiting for events](#waiting-for-events) - - [Managing cookies](#managing-cookies) - - [Take screenshots and prints](#take-screenshots-and-prints) - - [Manage and navigate through opened tabs](#manage-and-navigate-through-opened-tabs) - - [Running javascript](#running-javascript) - - [Interacting with the page elements](#interacting-with-the-page-elements) - - [Inputs](#inputs) - - [File inputs](#file-inputs) - - [Selects](#selects) - - [Lists](#lists) - - [Buttons and links](#buttons-and-links) - - [Tables](#tables) - - [Medias](#medias) +- [Interacting with the current page](#interacting-with-the-current-page) + - [Pressing keys](#pressing-keys) + - [Waiting for events](#waiting-for-events) + - [Managing cookies](#managing-cookies) + - [Take screenshots and prints](#take-screenshots-and-prints) + - [Manage and navigate through opened tabs](#manage-and-navigate-through-opened-tabs) + - [Running javascript](#running-javascript) +- [Interacting with the page elements](#interacting-with-the-page-elements) + - [Inputs](#inputs) + - [File inputs](#file-inputs) + - [Selects](#selects) + - [Lists](#lists) + - [Buttons and links](#buttons-and-links) + - [Tables](#tables) + - [Medias](#medias) ## Configure Selenium integration From de7a01999afa0d8d3d0448b57303fba0d806bfb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Fri, 19 Jul 2024 16:15:25 -0300 Subject: [PATCH 16/78] feat - moving print table to commons --- fastrpa/commons.py | 10 ++++++++++ fastrpa/core/elements.py | 9 ++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/fastrpa/commons.py b/fastrpa/commons.py index 80160fc..1a4a92f 100644 --- a/fastrpa/commons.py +++ b/fastrpa/commons.py @@ -1,5 +1,8 @@ +from typing import Iterable from urllib.parse import urlparse from selenium.webdriver import ChromeOptions +from rich.table import Table +from rich.console import Console import os import requests @@ -34,3 +37,10 @@ def get_file_path(path: str) -> str: def get_domain(webdriver: WebDriver) -> str: return urlparse(webdriver.current_url).netloc + + +def print_table(headers: Iterable[str], rows: Iterable[str]): + rich_table = Table(*headers) + for row in rows: + rich_table.add_row(*row) + Console().print(rich_table) diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index c2691f7..d1d4bd2 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -4,10 +4,8 @@ from selenium.webdriver.support.ui import Select from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.common.by import By -from rich.table import Table -from rich.console import Console -from fastrpa.commons import get_file_path +from fastrpa.commons import get_file_path, print_table from fastrpa.dataclasses import Item, Option from fastrpa.types import WebDriver @@ -318,7 +316,4 @@ def has_content(self, value: str) -> bool: return value in cells_content def print(self): - rich_table = Table(*self.headers) - for row in self.rows: - rich_table.add_row(*row) - Console().print(rich_table) + print_table(self.headers, self.rows) From c079c14a61ef2f9c3993da8a2bab765264245d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Fri, 19 Jul 2024 18:20:10 -0300 Subject: [PATCH 17/78] feat - new formatting --- fastrpa/app.py | 23 +++++++++++++---------- fastrpa/core/elements.py | 18 +++++------------- fastrpa/core/keyboard.py | 12 ++++++++++++ fastrpa/core/screenshot.py | 16 ++++------------ fastrpa/core/timer.py | 5 +++-- fastrpa/factory.py | 15 ++++++--------- fastrpa/settings.py | 3 +-- pyproject.toml | 2 +- 8 files changed, 45 insertions(+), 49 deletions(-) diff --git a/fastrpa/app.py b/fastrpa/app.py index ad253bb..25b9a18 100644 --- a/fastrpa/app.py +++ b/fastrpa/app.py @@ -7,7 +7,7 @@ from fastrpa.core.cookies import Cookies from fastrpa.core.screenshot import Screenshot from fastrpa.exceptions import ElementNotCompatible -from fastrpa.settings import VISIBILITY_TIMEOUT +from fastrpa.settings import DEFAULT_TIMEOUT from fastrpa.core.elements import ( Element, InputElement, @@ -33,12 +33,12 @@ def __init__( self, url: str, webdriver: WebDriver, - visibility_timeout_seconds: int, + timeout_seconds: int, ): self.starter_url = url self.webdriver = webdriver self.webdriver.get(self.starter_url) - self.visibility_timeout_seconds = visibility_timeout_seconds + self.timeout_seconds = timeout_seconds self.keyboard = Keyboard(self.webdriver) self.timer = Timer(self.webdriver) self.screenshot = Screenshot(self.webdriver) @@ -53,16 +53,19 @@ def url(self) -> str: def domain(self) -> str: return get_domain(self.webdriver) + def browse(self, url: str): + self.webdriver.get(url) + + def refresh(self): + self.webdriver.refresh() + def reset(self): self.webdriver.get(self.starter_url) - def has_content(self, value: str) -> bool: - return value in self.webdriver.page_source - def element(self, xpath: str, wait: bool = True) -> Element | SpecificElement: if not wait: return self.factory.get(xpath) - return self.factory.get_when_available(xpath, self.visibility_timeout_seconds) + return self.factory.get_when_available(xpath, self.timeout_seconds) def elements(self, xpath: str) -> list[Element | SpecificElement]: return self.factory.get_many(xpath) @@ -105,12 +108,12 @@ def __init__( webdriver: WebDriver | None = None, options_class: BrowserOptionsClass = ChromeOptions, browser_arguments: list[str] | None = None, - visibility_timeout_seconds: int = VISIBILITY_TIMEOUT, + timeout_seconds: int = DEFAULT_TIMEOUT, ): self._browser_options: BrowserOptions | None = None self._webdriver = webdriver self._options_class = options_class - self.visibility_timeout_seconds = visibility_timeout_seconds + self.timeout_seconds = timeout_seconds if browser_arguments: self.browser_arguments = browser_arguments @@ -133,4 +136,4 @@ def __del__(self): self.webdriver.quit() def browse(self, url: str) -> Web: - return Web(url, self.webdriver, self.visibility_timeout_seconds) + return Web(url, self.webdriver, self.timeout_seconds) diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index d1d4bd2..39c23a1 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -117,8 +117,7 @@ def options_values(self) -> list[str | None]: def options_labels(self) -> list[str | None]: if self._options_labels is None: self._options_labels = [ - option.get_attribute('innerText') - for option in self.select_element.options + option.get_attribute('innerText') for option in self.select_element.options ] return self._options_labels @@ -181,17 +180,13 @@ def items(self) -> list[Item]: @property def items_ids(self) -> list[str | None]: if not self._items_ids: - self._items_ids = [ - item.get_attribute('ids') for item in self.items_elements - ] + self._items_ids = [item.get_attribute('ids') for item in self.items_elements] return self._items_ids @property def items_labels(self) -> list[str | None]: if self._items_labels is None: - self._items_labels = [ - item.get_attribute('innerText') for item in self.items_elements - ] + self._items_labels = [item.get_attribute('innerText') for item in self.items_elements] return self._items_labels def click_in_item(self, label: str | None = None, id: str | None = None): @@ -297,9 +292,7 @@ def rows(self) -> list[list[str | None]]: self._rows = rows_content return self._rows - def column_values( - self, name: str | None = None, index: int | None = None - ) -> list[str | None]: + def column_values(self, name: str | None = None, index: int | None = None) -> list[str | None]: if (not name) and (index is None): raise ValueError('You must provide at least "name" or "index"!') @@ -310,8 +303,7 @@ def column_values( def has_content(self, value: str) -> bool: cells_content = [ - cell.get_attribute('innerText') - for cell in self.source.find_elements('.//td') + cell.get_attribute('innerText') for cell in self.source.find_elements('.//td') ] return value in cells_content diff --git a/fastrpa/core/keyboard.py b/fastrpa/core/keyboard.py index fa37353..d5473dd 100644 --- a/fastrpa/core/keyboard.py +++ b/fastrpa/core/keyboard.py @@ -21,3 +21,15 @@ def tab(self): def enter(self): self._press_key(Keys.ENTER) + + def backspace(self): + self._press_key(Keys.BACKSPACE) + + def home(self): + self._press_key(Keys.HOME) + + def page_up(self): + self._press_key(Keys.PAGE_UP) + + def page_down(self): + self._press_key(Keys.PAGE_DOWN) diff --git a/fastrpa/core/screenshot.py b/fastrpa/core/screenshot.py index a294ae3..d45ca60 100644 --- a/fastrpa/core/screenshot.py +++ b/fastrpa/core/screenshot.py @@ -14,9 +14,7 @@ def _file_path(self, path: str | None = None) -> str: @property def max_width(self) -> int: - return self.webdriver.execute_script( - 'return document.documentElement.scrollWidth' - ) + return self.webdriver.execute_script('return document.documentElement.scrollWidth') @property def max_height(self) -> int: @@ -33,14 +31,8 @@ def full_page(self, path: str | None = None): try: self.webdriver.set_window_size(self.max_width, self.max_height) - self.webdriver.find_element(By.XPATH, '//body').screenshot( - self._file_path(path) - ) + self.webdriver.find_element(By.XPATH, '//body').screenshot(self._file_path(path)) finally: - self.webdriver.set_window_size( - starter_size['width'], starter_size['height'] - ) - self.webdriver.set_window_position( - starter_position['x'], starter_position['y'] - ) + self.webdriver.set_window_size(starter_size['width'], starter_size['height']) + self.webdriver.set_window_position(starter_position['x'], starter_position['y']) diff --git a/fastrpa/core/timer.py b/fastrpa/core/timer.py index f6915b2..72ce449 100644 --- a/fastrpa/core/timer.py +++ b/fastrpa/core/timer.py @@ -4,6 +4,7 @@ from selenium.webdriver.support import expected_conditions from selenium.webdriver.support.ui import WebDriverWait +from fastrpa.settings import DEFAULT_TIMEOUT from fastrpa.types import WebDriver @@ -11,12 +12,12 @@ class Timer: def __init__(self, webdriver: WebDriver): self.webdriver = webdriver - def wait_until_hide(self, xpath: str, timeout_seconds: int = 15): + def wait_until_hide(self, xpath: str, timeout_seconds: int = DEFAULT_TIMEOUT): WebDriverWait(self.webdriver, timeout_seconds).until_not( expected_conditions.element_to_be_clickable((By.XPATH, xpath)) ) - def wait_until_present(self, xpath: str, timeout_seconds: int = 15): + def wait_until_present(self, xpath: str, timeout_seconds: int = DEFAULT_TIMEOUT): WebDriverWait(self.webdriver, timeout_seconds).until( expected_conditions.element_to_be_clickable((By.XPATH, xpath)) ) diff --git a/fastrpa/factory.py b/fastrpa/factory.py index f082c22..b3b02de 100644 --- a/fastrpa/factory.py +++ b/fastrpa/factory.py @@ -16,6 +16,7 @@ TableElement, ) from fastrpa.exceptions import ElementNotFound, ElementNotFoundAfterTime +from fastrpa.settings import DEFAULT_TIMEOUT from fastrpa.types import WebDriver @@ -30,9 +31,7 @@ def element_class(self, element: WebElement) -> Type[Element]: if element.tag_name in ['ol', 'ul']: return ListElement - elif all( - [element.tag_name == 'input', element.get_attribute('type') == 'file'] - ): + elif all([element.tag_name == 'input', element.get_attribute('type') == 'file']): return FileInputElement elif element.tag_name == 'input': @@ -49,16 +48,16 @@ def element_class(self, element: WebElement) -> Type[Element]: return Element - def get_when_available(self, xpath: str, timeout: int = 15) -> Element: + def get_when_available(self, xpath: str, timeout_seconds: int = DEFAULT_TIMEOUT) -> Element: try: - selenium_element = WebDriverWait(self.webdriver, timeout).until( + selenium_element = WebDriverWait(self.webdriver, timeout_seconds).until( expected_conditions.presence_of_element_located((By.XPATH, xpath)) ) element_class = self.element_class(selenium_element) return element_class(selenium_element, self.webdriver) except TimeoutException: - raise ElementNotFoundAfterTime(xpath, timeout) + raise ElementNotFoundAfterTime(xpath, timeout_seconds) def get(self, xpath: str) -> Element: try: @@ -75,9 +74,7 @@ def get_many(self, xpath: str) -> list[Element]: try: for selenium_element in self.webdriver.find_elements(By.XPATH, xpath): element_class = self.element_class(selenium_element) - elements_to_return.append( - element_class(selenium_element, self.webdriver) - ) + elements_to_return.append(element_class(selenium_element, self.webdriver)) return elements_to_return diff --git a/fastrpa/settings.py b/fastrpa/settings.py index 0f9fb8d..bf30974 100644 --- a/fastrpa/settings.py +++ b/fastrpa/settings.py @@ -1,2 +1 @@ -VISIBILITY_TIMEOUT = 10 -HIDDEN_TIMEOUT = 60 +DEFAULT_TIMEOUT = 15 diff --git a/pyproject.toml b/pyproject.toml index 1e4c111..969efe7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.ruff] -line-length = 88 +line-length = 100 [tool.ruff.format] quote-style = "single" From 56b141e3b870e6d50946cfd7e6a2144b9be5fb17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Fri, 19 Jul 2024 18:22:49 -0300 Subject: [PATCH 18/78] style - new line length --- fastrpa/app.py | 12 +++++++----- fastrpa/commons.py | 4 +++- fastrpa/core/elements.py | 30 ++++++++++++++++++++++-------- fastrpa/core/screenshot.py | 16 ++++++++++++---- fastrpa/core/timer.py | 8 ++++++-- fastrpa/factory.py | 27 +++++++++++++++++++++------ fastrpa/types.py | 4 +++- pyproject.toml | 2 +- 8 files changed, 75 insertions(+), 28 deletions(-) diff --git a/fastrpa/app.py b/fastrpa/app.py index 25b9a18..33423e8 100644 --- a/fastrpa/app.py +++ b/fastrpa/app.py @@ -1,4 +1,4 @@ -from typing import Type, TypeVar +from typing import Type, TypeVar, Union from selenium.webdriver import Remote, ChromeOptions from fastrpa.commons import ( get_browser_options, @@ -26,6 +26,7 @@ SpecificElement = TypeVar('SpecificElement', bound=Element) +GenericElement = Union[Element, SpecificElement] class Web: @@ -55,19 +56,19 @@ def domain(self) -> str: def browse(self, url: str): self.webdriver.get(url) - + def refresh(self): self.webdriver.refresh() def reset(self): self.webdriver.get(self.starter_url) - def element(self, xpath: str, wait: bool = True) -> Element | SpecificElement: + def element(self, xpath: str, wait: bool = True) -> GenericElement: if not wait: return self.factory.get(xpath) return self.factory.get_when_available(xpath, self.timeout_seconds) - def elements(self, xpath: str) -> list[Element | SpecificElement]: + def elements(self, xpath: str) -> list[GenericElement]: return self.factory.get_many(xpath) def _specific_element( @@ -122,7 +123,8 @@ def __init__( def browser_options(self) -> BrowserOptions: if self._browser_options is None: self._browser_options = get_browser_options( - options=self.browser_arguments, options_class=self._options_class + options=self.browser_arguments, + options_class=self._options_class, ) return self._browser_options diff --git a/fastrpa/commons.py b/fastrpa/commons.py index 1a4a92f..8469cae 100644 --- a/fastrpa/commons.py +++ b/fastrpa/commons.py @@ -25,7 +25,9 @@ def get_file_path(path: str) -> str: return path file_response = requests.get(path) - file_extension = mimetypes.guess_extension(file_response.headers['Content-Type']) + file_extension = mimetypes.guess_extension( + file_response.headers['Content-Type'] + ) file_hash = abs(hash(file_response.content)) download_path = f'/tmp/{file_hash}{file_extension}' diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index 39c23a1..6ce1932 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -109,7 +109,8 @@ def select_element(self) -> Select: def options_values(self) -> list[str | None]: if self._options_values is None: self._options_values = [ - option.get_attribute('value') for option in self.select_element.options + option.get_attribute('value') + for option in self.select_element.options ] return self._options_values @@ -117,7 +118,8 @@ def options_values(self) -> list[str | None]: def options_labels(self) -> list[str | None]: if self._options_labels is None: self._options_labels = [ - option.get_attribute('innerText') for option in self.select_element.options + option.get_attribute('innerText') + for option in self.select_element.options ] return self._options_labels @@ -125,7 +127,10 @@ def options_labels(self) -> list[str | None]: def options(self) -> list[Option]: if self._options is None: self._options = [ - Option(option.get_attribute('value'), option.get_attribute('innerText')) + Option( + option.get_attribute('value'), + option.get_attribute('innerText'), + ) for option in self.select_element.options ] return self._options @@ -180,13 +185,17 @@ def items(self) -> list[Item]: @property def items_ids(self) -> list[str | None]: if not self._items_ids: - self._items_ids = [item.get_attribute('ids') for item in self.items_elements] + self._items_ids = [ + item.get_attribute('ids') for item in self.items_elements + ] return self._items_ids @property def items_labels(self) -> list[str | None]: if self._items_labels is None: - self._items_labels = [item.get_attribute('innerText') for item in self.items_elements] + self._items_labels = [ + item.get_attribute('innerText') for item in self.items_elements + ] return self._items_labels def click_in_item(self, label: str | None = None, id: str | None = None): @@ -286,13 +295,17 @@ def rows(self) -> list[list[str | None]]: rows_content.append( [ cell.get_attribute('innerText') - for cell in element.find_elements(By.XPATH, './/td | .//th') + for cell in element.find_elements( + By.XPATH, './/td | .//th' + ) ] ) self._rows = rows_content return self._rows - def column_values(self, name: str | None = None, index: int | None = None) -> list[str | None]: + def column_values( + self, name: str | None = None, index: int | None = None + ) -> list[str | None]: if (not name) and (index is None): raise ValueError('You must provide at least "name" or "index"!') @@ -303,7 +316,8 @@ def column_values(self, name: str | None = None, index: int | None = None) -> li def has_content(self, value: str) -> bool: cells_content = [ - cell.get_attribute('innerText') for cell in self.source.find_elements('.//td') + cell.get_attribute('innerText') + for cell in self.source.find_elements('.//td') ] return value in cells_content diff --git a/fastrpa/core/screenshot.py b/fastrpa/core/screenshot.py index d45ca60..a294ae3 100644 --- a/fastrpa/core/screenshot.py +++ b/fastrpa/core/screenshot.py @@ -14,7 +14,9 @@ def _file_path(self, path: str | None = None) -> str: @property def max_width(self) -> int: - return self.webdriver.execute_script('return document.documentElement.scrollWidth') + return self.webdriver.execute_script( + 'return document.documentElement.scrollWidth' + ) @property def max_height(self) -> int: @@ -31,8 +33,14 @@ def full_page(self, path: str | None = None): try: self.webdriver.set_window_size(self.max_width, self.max_height) - self.webdriver.find_element(By.XPATH, '//body').screenshot(self._file_path(path)) + self.webdriver.find_element(By.XPATH, '//body').screenshot( + self._file_path(path) + ) finally: - self.webdriver.set_window_size(starter_size['width'], starter_size['height']) - self.webdriver.set_window_position(starter_position['x'], starter_position['y']) + self.webdriver.set_window_size( + starter_size['width'], starter_size['height'] + ) + self.webdriver.set_window_position( + starter_position['x'], starter_position['y'] + ) diff --git a/fastrpa/core/timer.py b/fastrpa/core/timer.py index 72ce449..0573612 100644 --- a/fastrpa/core/timer.py +++ b/fastrpa/core/timer.py @@ -12,12 +12,16 @@ class Timer: def __init__(self, webdriver: WebDriver): self.webdriver = webdriver - def wait_until_hide(self, xpath: str, timeout_seconds: int = DEFAULT_TIMEOUT): + def wait_until_hide( + self, xpath: str, timeout_seconds: int = DEFAULT_TIMEOUT + ): WebDriverWait(self.webdriver, timeout_seconds).until_not( expected_conditions.element_to_be_clickable((By.XPATH, xpath)) ) - def wait_until_present(self, xpath: str, timeout_seconds: int = DEFAULT_TIMEOUT): + def wait_until_present( + self, xpath: str, timeout_seconds: int = DEFAULT_TIMEOUT + ): WebDriverWait(self.webdriver, timeout_seconds).until( expected_conditions.element_to_be_clickable((By.XPATH, xpath)) ) diff --git a/fastrpa/factory.py b/fastrpa/factory.py index b3b02de..b981058 100644 --- a/fastrpa/factory.py +++ b/fastrpa/factory.py @@ -31,7 +31,12 @@ def element_class(self, element: WebElement) -> Type[Element]: if element.tag_name in ['ol', 'ul']: return ListElement - elif all([element.tag_name == 'input', element.get_attribute('type') == 'file']): + elif all( + [ + element.tag_name == 'input', + element.get_attribute('type') == 'file', + ] + ): return FileInputElement elif element.tag_name == 'input': @@ -48,10 +53,16 @@ def element_class(self, element: WebElement) -> Type[Element]: return Element - def get_when_available(self, xpath: str, timeout_seconds: int = DEFAULT_TIMEOUT) -> Element: + def get_when_available( + self, xpath: str, timeout_seconds: int = DEFAULT_TIMEOUT + ) -> Element: try: - selenium_element = WebDriverWait(self.webdriver, timeout_seconds).until( - expected_conditions.presence_of_element_located((By.XPATH, xpath)) + selenium_element = WebDriverWait( + self.webdriver, timeout_seconds + ).until( + expected_conditions.presence_of_element_located( + (By.XPATH, xpath) + ) ) element_class = self.element_class(selenium_element) return element_class(selenium_element, self.webdriver) @@ -72,9 +83,13 @@ def get_many(self, xpath: str) -> list[Element]: elements_to_return = [] try: - for selenium_element in self.webdriver.find_elements(By.XPATH, xpath): + for selenium_element in self.webdriver.find_elements( + By.XPATH, xpath + ): element_class = self.element_class(selenium_element) - elements_to_return.append(element_class(selenium_element, self.webdriver)) + elements_to_return.append( + element_class(selenium_element, self.webdriver) + ) return elements_to_return diff --git a/fastrpa/types.py b/fastrpa/types.py index 4dfe748..eb08df4 100644 --- a/fastrpa/types.py +++ b/fastrpa/types.py @@ -12,7 +12,9 @@ WebDriver = Remote | Chrome | Safari | Firefox BrowserOptions = ChromeOptions | SafariOptions | FirefoxOptions -BrowserOptionsClass = Type[ChromeOptions] | Type[SafariOptions] | Type[FirefoxOptions] +BrowserOptionsClass = ( + Type[ChromeOptions] | Type[SafariOptions] | Type[FirefoxOptions] +) __all__ = ( diff --git a/pyproject.toml b/pyproject.toml index 969efe7..a38c1ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.ruff] -line-length = 100 +line-length = 80 [tool.ruff.format] quote-style = "single" From 208fbbd0a7fd727188a863583a32f52a49d56741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Fri, 19 Jul 2024 19:17:36 -0300 Subject: [PATCH 19/78] feat - starter elements documentation --- README.md | 261 ++++++++++++++++++++++++++++++--------- fastrpa/core/elements.py | 28 ++--- 2 files changed, 215 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 2cc36ec..440aebf 100644 --- a/README.md +++ b/README.md @@ -40,15 +40,15 @@ docker run --name selenium -d -p 4444:4444 -p 7900:7900 seleniarm/standalone-chr By default, FastRPA always connect to `http://localhost:4444`. If you want to change it, just create your own Selenium instance. ```python -from fastrpa import FastRPA -from selenium.webdriver import Firefox, FirefoxOptions +>>> from fastrpa import FastRPA +>>> from selenium.webdriver import Firefox, FirefoxOptions -options = FirefoxOptions() -options.add_argument(...) -options.add_argument(...) +>>> options = FirefoxOptions() +>>> options.add_argument(...) +>>> options.add_argument(...) -webdriver = Firefox(options) -app = FastRPA(webdriver) +>>> webdriver = Firefox(options) +>>> app = FastRPA(webdriver) ``` ## The FastRPA instance @@ -56,12 +56,11 @@ app = FastRPA(webdriver) This is just a configuration object. You will need it just to start your web navegation. ```python -from fastrpa import FastRPA - -app = FastRPA() -web = app.browse('https:...') -type(web) -<<< fastrpa.app.Web +>>> from fastrpa import FastRPA +>>> app = FastRPA() +>>> web = app.browse('https:...') +>>> type(web) +fastrpa.app.Web ``` ## Interacting with the current page @@ -80,23 +79,23 @@ It includes, managing: You can access these abstractions by calling it from the `Web` object. ```python -web.keyboard ->>> +>>> web.keyboard + -web.timer ->>> +>>> web.timer + -web.cookies ->>> +>>> web.cookies + -web.screenshot ->>> +>>> web.screenshot + -web.tabs ->>> +>>> web.tabs + -web.console ->>> +>>> web.console + ``` ### Pressing keys @@ -116,20 +115,20 @@ You can wait some time before or after execute some action with the automation. You can also wait for element visibility. By default the timeout is 15 seconds, but you can also specify it. ```python -app = FastRPA() -web = app.browse('https:...') +>>> app = FastRPA() +>>> web = app.browse('https:...') # Wait a maximum of 15 seconds until a button is present -web.timer.wait_until_present('//button[@id="myBtn"]') +>>> web.timer.wait_until_present('//button[@id="myBtn"]') # Wait a maximum of custom seconds until a button is present -web.timer.wait_until_present('//button[@id="myBtn"]', 30) +>>> web.timer.wait_until_present('//button[@id="myBtn"]', 30) # Wait a maximum of 15 seconds until a button is hide -web.timer.wait_until_hide('//button[@id="myBtn"]') +>>> web.timer.wait_until_hide('//button[@id="myBtn"]') # Wait a maximum of custom seconds until a button is hide -web.timer.wait_until_hide('//button[@id="myBtn"]', 30) +>>> web.timer.wait_until_hide('//button[@id="myBtn"]', 30) ``` ### Managing cookies @@ -137,36 +136,36 @@ web.timer.wait_until_hide('//button[@id="myBtn"]', 30) Follow the examples below to manage cookies on the current domain. ```python -app = FastRPA() -web = app.browse('https:...') +>>> app = FastRPA() +>>> web = app.browse('https:...') # Get the list of cookies on the current domain -web.cookies.list ->>> [Cookie(...), Cookie(...)] +>>> web.cookies.list +[Cookie(...), Cookie(...)] # Get the list of names from the cookies on the current domain -web.cookies.list_names ->>> ['JSESSIONID', '_ga', ...] +>>> web.cookies.list_names +['JSESSIONID', '_ga', ...] # Check if a cookie exists on the current domain -'my_cookie' in web.cookies ->>> True +>>> 'my_cookie' in web.cookies +True # Check if a cookie stores some value -web.cookies.check('my_cookie', 'value') ->>> False +>>> web.cookies.check('my_cookie', 'value') +False # Get a cookie on the current domain -web.cookies.get('my_cookie') ->>> Cookie(name='...', value='...', domain='...', path='/', secure=True, http_only=True, same_site='Strict') +>>> web.cookies.get('my_cookie') +Cookie(name='...', value='...', domain='...', path='/', secure=True, http_only=True, same_site='Strict') # Try to get a cookie that does not exist the current domain -web.cookies.get('my_cookie') ->>> None +>>> web.cookies.get('my_cookie') +None # Add a new cookie on the current domain -web.cookies.add('my_cookie', 'value', secure=False) ->>> Cookie(name='my_cookie', value='value', domain='...', path='/', secure=False, http_only=True, same_site='Strict') +>>> web.cookies.add('my_cookie', 'value', secure=False) +Cookie(name='my_cookie', value='value', domain='...', path='/', secure=False, http_only=True, same_site='Strict') ``` ### Take screenshots and prints @@ -174,20 +173,20 @@ web.cookies.add('my_cookie', 'value', secure=False) By default, all screenshot methods save the files in the current active directory. ```python -app = FastRPA() -web = app.browse('https:...') +>>> app = FastRPA() +>>> web = app.browse('https:...') # Take a screenshot just containing the current viewport size -web.screenshot.viewport() +>>> web.screenshot.viewport() # Take a screenshot just containing the current viewport size, and save the file in the specified path -web.screenshot.viewport('/my/screenshot/path.png') +>>> web.screenshot.viewport('/my/screenshot/path.png') # Take a screenshot containing the complete page -web.screenshot.full_page() +>>> web.screenshot.full_page() # Take a screenshot containing the complete page, and save the file in the specified path -web.screenshot.full_page('/my/screenshot/path.png') +>>> web.screenshot.full_page('/my/screenshot/path.png') ``` You can see examples of both screenshot methods below. @@ -206,19 +205,167 @@ Comming soon... ## Interacting with the page elements -Need to write. +> FastRPA is totally based on **xpath locations**. It means that does not exists any way, but xpath, to access elements from the web pages. This is a important concept that guarants the consistence on framework's code base. +> +> If you want to obtain elements using another identifier, just write an xpath that wraps that identifier. For example, if you want to get a div with an id `my_div`, just use the xpath `//*[@id="my_div"]`. You can use a site like [xpather.com](http://xpather.com/) to help you building your xpaths. + +To start our interactions with page elements, we just need to obtain these with the methods shown below. + +```python +>>> app = FastRPA() +>>> web = app.browse('https:...') + +# To get just one element, or the first found +>>> web.element('//*[@id="my_div"]') + + +# To get all elements found +>>> web.elements('//*[@id="my_div"]') +[, + ] +``` + +There is some abstractions that implements actions and rules for specific elements. There is listed below. + +- `Element`, for any element +- [`InputElement`](#inputs), for fillable inputs (``) +- [`FileInputElement`](#file-inputs), for file inputs (``) +- [`SelectElement`](#selects), for selects (``) @@ -278,6 +289,8 @@ False ### Inputs +Interactions with `input` and `textarea` tags. + ```python # Gets the right element class for the xpath >>> my_input = web.element('//*[id="myInput"]') @@ -304,6 +317,8 @@ fastrpa.core.elements.InputElement ### File inputs +Interactions with `input` with attribute `type="file"`. + ```python # Gets the right element class for the xpath >>> my_input = web.element('//*[id="myFileInput"]') @@ -324,6 +339,8 @@ fastrpa.core.elements.FileInputElement ### Selects +Interactions with `select` tag. + ```python # Gets the right element class for the xpath >>> my_select = web.element('//*[id="mySelect"]') From e227c06bdd6f18802de7b94063c3879bee8bbdcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Sun, 21 Jul 2024 15:06:04 -0300 Subject: [PATCH 21/78] feat - generical keyboard --- fastrpa/core/keyboard.py | 64 ++++++++++++++++++++++++++-------------- fastrpa/exceptions.py | 7 +++++ 2 files changed, 49 insertions(+), 22 deletions(-) diff --git a/fastrpa/core/keyboard.py b/fastrpa/core/keyboard.py index d5473dd..85d08a9 100644 --- a/fastrpa/core/keyboard.py +++ b/fastrpa/core/keyboard.py @@ -1,35 +1,55 @@ +from typing import Any from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.action_chains import ActionChains +from fastrpa.exceptions import KeyDoesNotExists from fastrpa.types import WebDriver class Keyboard: def __init__(self, webdriver: WebDriver): + self._keys_mapping: dict[str, str] | None = None + self._keys: list[str] | None = None self.webdriver = webdriver self.actions = ActionChains(self.webdriver) - def _press_key(self, key: str): - self.actions.send_keys(key) + @property + def keys_mapping(self) -> dict[str, str]: + if self._keys_mapping is None: + self._keys_mapping = { + attr: getattr(Keys, attr) + for attr in dir(Keys) + if not attr.startswith('_') + } + return self._keys_mapping + + @property + def keys(self) -> list[str]: + if self._keys is None: + self._keys = list(self.keys_mapping.keys()) + return self._keys + + def key_name(self, name: str) -> str: + return name.upper().replace(' ', '_') + + def has_key(self, name: str | None = None, code: str | None = None) -> bool: + if name: + return self.key_name(name) in self.keys + elif code: + return code in self.keys_mapping.values() + raise ValueError('You must provide at least "name" or "code"!') + + def key_code(self, key: str) -> str: + key_name = self.key_name(key) + if key_name in self.keys: + return self.keys_mapping[key_name] + elif key in self.keys_mapping.values(): + return key + raise KeyDoesNotExists(key) + + def press(self, key: str): + self.actions.send_keys(self.key_code(key)) self.actions.perform() - def esc(self): - self._press_key(Keys.ESCAPE) - - def tab(self): - self._press_key(Keys.TAB) - - def enter(self): - self._press_key(Keys.ENTER) - - def backspace(self): - self._press_key(Keys.BACKSPACE) - - def home(self): - self._press_key(Keys.HOME) - - def page_up(self): - self._press_key(Keys.PAGE_UP) - - def page_down(self): - self._press_key(Keys.PAGE_DOWN) + def __contains__(self, value: Any) -> bool: + return self.has_key(value) or self.has_key(code=value) diff --git a/fastrpa/exceptions.py b/fastrpa/exceptions.py index 99d84a9..e3d09c7 100644 --- a/fastrpa/exceptions.py +++ b/fastrpa/exceptions.py @@ -24,3 +24,10 @@ class CookieNotAdded(Exception): def __init__(self, cookie_name: str) -> None: super().__init__(self.message.format(cookie_name)) + + +class KeyDoesNotExists(Exception): + message = 'The key [{}] does not exists!' + + def __init__(self, key: str) -> None: + super().__init__(self.message.format(key)) From e2af27f68f39e8b38f233f89162ba2e06a250c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Sun, 21 Jul 2024 15:14:33 -0300 Subject: [PATCH 22/78] feat - improvements in timer class --- fastrpa/core/timer.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/fastrpa/core/timer.py b/fastrpa/core/timer.py index 0573612..0d93139 100644 --- a/fastrpa/core/timer.py +++ b/fastrpa/core/timer.py @@ -12,19 +12,22 @@ class Timer: def __init__(self, webdriver: WebDriver): self.webdriver = webdriver + def wait(self, timeout_seconds: int = DEFAULT_TIMEOUT) -> WebDriverWait: + return WebDriverWait(self.webdriver, timeout_seconds) + def wait_until_hide( self, xpath: str, timeout_seconds: int = DEFAULT_TIMEOUT ): - WebDriverWait(self.webdriver, timeout_seconds).until_not( + self.wait(timeout_seconds).until_not( expected_conditions.element_to_be_clickable((By.XPATH, xpath)) ) def wait_until_present( self, xpath: str, timeout_seconds: int = DEFAULT_TIMEOUT ): - WebDriverWait(self.webdriver, timeout_seconds).until( + self.wait(timeout_seconds).until( expected_conditions.element_to_be_clickable((By.XPATH, xpath)) ) - def wait_seconds(self, seconds: int): + def wait_seconds(self, seconds: float): sleep(seconds) From 8558896eb468018a030c2e82fac3af445c75d6b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Sun, 21 Jul 2024 20:16:45 -0300 Subject: [PATCH 23/78] feat - take just screenshot bytes content --- fastrpa/core/screenshot.py | 69 ++++++++++++++++++++++++++++++++------ fastrpa/exceptions.py | 17 +++++++--- 2 files changed, 71 insertions(+), 15 deletions(-) diff --git a/fastrpa/core/screenshot.py b/fastrpa/core/screenshot.py index a294ae3..fb7e957 100644 --- a/fastrpa/core/screenshot.py +++ b/fastrpa/core/screenshot.py @@ -1,5 +1,6 @@ from datetime import datetime from selenium.webdriver.common.by import By +from fastrpa.exceptions import ScreenshotNotTaken from fastrpa.types import WebDriver @@ -11,6 +12,17 @@ def _file_path(self, path: str | None = None) -> str: if path is None: path = f'{datetime.now().isoformat()}.png' return path + + def _restore_window(self, size: dict[str, int], position: dict[str, int], is_maximized: bool): + if not is_maximized: + self.webdriver.set_window_size( + size['width'], size['height'] + ) + self.webdriver.set_window_position( + position['x'], position['y'] + ) + else: + self.webdriver.maximize_window() @property def max_width(self) -> int: @@ -23,24 +35,61 @@ def max_height(self) -> int: return self.webdriver.execute_script( 'return document.documentElement.scrollHeight + (window.innerHeight * 0.2)' ) + + @property + def is_maximized(self) -> bool: + window_size = self.webdriver.get_window_size() + screen_width = self.webdriver.execute_script("return screen.availWidth") + screen_height = self.webdriver.execute_script("return screen.availHeight") + return (window_size['width'] == screen_width) and (window_size['height'] == screen_height) - def viewport(self, path: str | None = None): - self.webdriver.save_screenshot(self._file_path(path)) - - def full_page(self, path: str | None = None): + @property + def image(self) -> bytes: + return self.webdriver.get_screenshot_as_png() + + @property + def full_page_image(self) -> bytes: starter_position = self.webdriver.get_window_position() starter_size = self.webdriver.get_window_size() + starter_is_maximized = self.is_maximized + image: bytes try: self.webdriver.set_window_size(self.max_width, self.max_height) - self.webdriver.find_element(By.XPATH, '//body').screenshot( + element = self.webdriver.find_element(By.XPATH, '//body') + image = element.screenshot_as_png + + finally: + self._restore_window( + size=starter_size, + position=starter_position, + is_maximized=starter_is_maximized + ) + return image + + def save_image(self, path: str | None = None): + success = self.webdriver.save_screenshot(self._file_path(path)) + if not success: + raise ScreenshotNotTaken('image', self.webdriver.current_url) + + def save_full_page_image(self, path: str | None = None): + starter_size = self.webdriver.get_window_size() + starter_position = self.webdriver.get_window_position() + starter_is_maximized = self.is_maximized + + try: + self.webdriver.set_window_size(self.max_width, self.max_height) + element = self.webdriver.find_element(By.XPATH, '//body') + success = element.screenshot( self._file_path(path) ) + if not success: + raise ScreenshotNotTaken('image', self.webdriver.current_url) + finally: - self.webdriver.set_window_size( - starter_size['width'], starter_size['height'] - ) - self.webdriver.set_window_position( - starter_position['x'], starter_position['y'] + self._restore_window( + size=starter_size, + position=starter_position, + is_maximized=starter_is_maximized ) diff --git a/fastrpa/exceptions.py b/fastrpa/exceptions.py index e3d09c7..1cd0302 100644 --- a/fastrpa/exceptions.py +++ b/fastrpa/exceptions.py @@ -1,33 +1,40 @@ class ElementNotFound(Exception): message = 'No one element [{}] was found!' - def __init__(self, xpath: str) -> None: + def __init__(self, xpath: str): super().__init__(self.message.format(xpath)) class ElementNotFoundAfterTime(Exception): message = 'Element [{}] not found after {} seconds!' - def __init__(self, xpath: str, timeout: int) -> None: + def __init__(self, xpath: str, timeout: int): super().__init__(self.message.format(xpath, timeout)) class ElementNotCompatible(Exception): message = 'Element [{}] is not compatible with {}!' - def __init__(self, xpath: str, class_name: type) -> None: + def __init__(self, xpath: str, class_name: type): super().__init__(self.message.format(xpath, class_name)) +class ScreenshotNotTaken(Exception): + message = 'The [{}] screenshot from [{}] was not taken!' + + def __init__(self, type: str, url: str): + super().__init__(self.message.format(type, url)) + + class CookieNotAdded(Exception): message = 'The cookie [{}] was not added!' - def __init__(self, cookie_name: str) -> None: + def __init__(self, cookie_name: str): super().__init__(self.message.format(cookie_name)) class KeyDoesNotExists(Exception): message = 'The key [{}] does not exists!' - def __init__(self, key: str) -> None: + def __init__(self, key: str): super().__init__(self.message.format(key)) From d4e5f0cccc754c823feec7769383e25f084f0f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Sun, 21 Jul 2024 20:17:03 -0300 Subject: [PATCH 24/78] feat - javascript console abstraction --- fastrpa/app.py | 2 ++ fastrpa/core/console.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 fastrpa/core/console.py diff --git a/fastrpa/app.py b/fastrpa/app.py index 33423e8..6198312 100644 --- a/fastrpa/app.py +++ b/fastrpa/app.py @@ -4,6 +4,7 @@ get_browser_options, get_domain, ) +from fastrpa.core.console import Console from fastrpa.core.cookies import Cookies from fastrpa.core.screenshot import Screenshot from fastrpa.exceptions import ElementNotCompatible @@ -44,6 +45,7 @@ def __init__( self.timer = Timer(self.webdriver) self.screenshot = Screenshot(self.webdriver) self.cookies = Cookies(self.webdriver) + self.console = Console(self.webdriver) self.factory = ElementFactory(self.webdriver) @property diff --git a/fastrpa/core/console.py b/fastrpa/core/console.py new file mode 100644 index 0000000..b2f3d86 --- /dev/null +++ b/fastrpa/core/console.py @@ -0,0 +1,18 @@ +from typing import Any +from fastrpa.types import WebDriver + + +class Console: + + def __init__(self, webdriver: WebDriver) -> None: + self.webdriver = webdriver + + def evaluate(self, code: str) -> Any: + return self.webdriver.execute_script(f'return {code}') + + def run(self, lines: list[str]) -> Any: + return self.webdriver.execute_script('\n'.join(lines)) + + def run_script(self, path: str) -> Any: + with open(path, 'r') as script: + return self.run(script.readlines()) From 3b004755f787bcad75ec01c4994b03f3a3c23c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Sun, 21 Jul 2024 20:17:15 -0300 Subject: [PATCH 25/78] feat - method to delete cookie --- fastrpa/core/cookies.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fastrpa/core/cookies.py b/fastrpa/core/cookies.py index c08a7ee..bbd7b06 100644 --- a/fastrpa/core/cookies.py +++ b/fastrpa/core/cookies.py @@ -36,6 +36,9 @@ def add(self, name: str, value: str, secure: bool = False) -> Cookie: if added_cookie := self.webdriver.get_cookie(name): return Cookie.from_selenium(added_cookie) raise CookieNotAdded(name) + + def delete(self, name: str): + self.webdriver.delete_cookie(name) def check(self, name: str, value: str) -> bool: if cookie := self.get(name): From fc45e1e3769b384e80777cf4b904ad199ba73b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Sun, 21 Jul 2024 20:17:51 -0300 Subject: [PATCH 26/78] feat - read inline css values for element --- fastrpa/core/elements.py | 61 ++++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index 902ea41..08cc214 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -13,7 +13,8 @@ class Element: _tag: str | None = None _id: str | None = None - _css_class: str | None = None + _css_class: list[str] | None = None + _css_inline: dict[str, str] | None = None _text: str | None = None _is_visible: bool | None = None @@ -38,10 +39,22 @@ def id(self) -> str | None: return self._id @property - def css_class(self) -> str | None: + def css_class(self) -> list[str] | None: if self._css_class is None: - self._css_class = self.attribute('class') + if classes := self.attribute('class'): + self._css_class = classes.split(' ') return self._css_class + + @property + def css_inline(self) -> dict[str, str] | None: + if self._css_inline is None: + if style := self.attribute('style'): + self._css_inline = {} + for css in style.strip().split(';'): + if css: + css_property, css_value = css.split(':') + self._css_inline[css_property.strip()] = css_value.strip() + return self._css_inline @property def text(self) -> str | None: @@ -150,16 +163,16 @@ def __contains__(self, key: Any) -> bool: class ListElement(Element): _is_ordered: bool | None = None - _items_elements: list[WebElement] | None = None + _items_sources: list[WebElement] | None = None _items: list[Item] | None = None _items_ids: list[str | None] | None = None _items_labels: list[str | None] | None = None @property - def items_elements(self) -> list[WebElement]: - if self._items_elements is None: - self._items_elements = self.source.find_elements(By.XPATH, './/li') - return self._items_elements + def items_sources(self) -> list[WebElement]: + if self._items_sources is None: + self._items_sources = self.source.find_elements(By.XPATH, './/li') + return self._items_sources @property def is_ordered(self) -> bool: @@ -172,7 +185,7 @@ def items(self) -> list[Item]: if self._items is None: self._items = [ Item(item.get_attribute('id'), item.get_attribute('innerText')) - for item in self.items_elements + for item in self.items_sources ] return self._items @@ -180,7 +193,7 @@ def items(self) -> list[Item]: def items_ids(self) -> list[str | None]: if not self._items_ids: self._items_ids = [ - item.get_attribute('ids') for item in self.items_elements + item.get_attribute('ids') for item in self.items_sources ] return self._items_ids @@ -188,7 +201,7 @@ def items_ids(self) -> list[str | None]: def items_labels(self) -> list[str | None]: if self._items_labels is None: self._items_labels = [ - item.get_attribute('innerText') for item in self.items_elements + item.get_attribute('innerText') for item in self.items_sources ] return self._items_labels @@ -196,7 +209,7 @@ def click_in_item(self, label: str | None = None, id: str | None = None): if not (label or id): raise ValueError('You must provide at least "label" or "id"!') - for item in self.items_elements: + for item in self.items_sources: if label and label == item.get_attribute('innerText'): self.actions.click(item) self.actions.perform() @@ -252,40 +265,40 @@ def submit(self, button: ButtonElement | None = None): class TableElement(Element): _headers: list[str | None] | None = None - _headers_elements: list[WebElement] | None = None + _headers_sources: list[WebElement] | None = None _rows: list[list[str | None]] | None = None - _rows_elements: list[WebElement] | None = None + _rows_sources: list[WebElement] | None = None @property - def headers_elements(self) -> list[WebElement]: - if self._headers_elements is None: + def headers_sources(self) -> list[WebElement]: + if self._headers_sources is None: first_row = self.source.find_element(By.XPATH, './/tr') - self._headers_elements = first_row.find_elements(By.XPATH, './/th') - return self._headers_elements + self._headers_sources = first_row.find_elements(By.XPATH, './/th') + return self._headers_sources @property def headers(self) -> list[str | None]: if self._headers is None: self._headers = [ header.get_attribute('innerText') if header else None - for header in self.headers_elements + for header in self.headers_sources ] return self._headers @property - def rows_elements(self) -> list[WebElement]: - if self._rows_elements is None: + def rows_sources(self) -> list[WebElement]: + if self._rows_sources is None: rows = self.source.find_elements(By.XPATH, './/tr') if self.headers: del rows[0] - self._rows_elements = rows - return self._rows_elements + self._rows_sources = rows + return self._rows_sources @property def rows(self) -> list[list[str | None]]: if self._rows is None: rows_content = [] - for element in self.rows_elements: + for element in self.rows_sources: rows_content.append( [ cell.get_attribute('innerText') From 3e62ea4239fb7c03fe963f567613d66f642a5428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Mon, 22 Jul 2024 11:13:51 -0300 Subject: [PATCH 27/78] feat - real time element values --- fastrpa/core/elements.py | 157 ++++++++++++++++----------------------- 1 file changed, 64 insertions(+), 93 deletions(-) diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index 08cc214..ab9ab75 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -13,10 +13,6 @@ class Element: _tag: str | None = None _id: str | None = None - _css_class: list[str] | None = None - _css_inline: dict[str, str] | None = None - _text: str | None = None - _is_visible: bool | None = None def __init__(self, source: WebElement, webdriver: WebDriver) -> None: self.source = source @@ -40,38 +36,31 @@ def id(self) -> str | None: @property def css_class(self) -> list[str] | None: - if self._css_class is None: - if classes := self.attribute('class'): - self._css_class = classes.split(' ') - return self._css_class + if classes := self.attribute('class'): + return classes.split(' ') + return [] @property - def css_inline(self) -> dict[str, str] | None: - if self._css_inline is None: - if style := self.attribute('style'): - self._css_inline = {} - for css in style.strip().split(';'): - if css: - css_property, css_value = css.split(':') - self._css_inline[css_property.strip()] = css_value.strip() - return self._css_inline + def css_inline(self) -> dict[str, str]: + to_return = {} + if style := self.attribute('style'): + for css in style.strip().split(';'): + if css: + css_property, css_value = css.split(':') + to_return[css_property.strip()] = css_value.strip() + return to_return @property def text(self) -> str | None: - if self._text is None: - if self.source.text: - self._text = self.source.text - elif value := self.attribute('value'): - self._text = value - else: - self._text = None - return self._text + if self.source.text: + return self.source.text + elif value := self.attribute('value'): + return value + return None @property def is_visible(self) -> bool: - if self._is_visible is None: - self._is_visible = self.source.is_displayed() - return self._is_visible + return self.source.is_displayed() def focus(self): self.actions.scroll_to_element(self.source) @@ -102,9 +91,6 @@ def attach_file(self, path: str): class SelectElement(Element): _select_source: Select | None = None - _options: list[Option] | None = None - _options_values: list[str | None] | None = None - _options_labels: list[str | None] | None = None @property def select_source(self) -> Select: @@ -114,33 +100,27 @@ def select_source(self) -> Select: @property def options_values(self) -> list[str | None]: - if self._options_values is None: - self._options_values = [ - option.get_attribute('value') - for option in self.select_source.options - ] - return self._options_values + return [ + option.get_attribute('value') + for option in self.select_source.options + ] @property def options_labels(self) -> list[str | None]: - if self._options_labels is None: - self._options_labels = [ - option.get_attribute('innerText') - for option in self.select_source.options - ] - return self._options_labels + return [ + option.get_attribute('innerText') + for option in self.select_source.options + ] @property def options(self) -> list[Option]: - if self._options is None: - self._options = [ - Option( - option.get_attribute('value'), - option.get_attribute('innerText'), - ) - for option in self.select_source.options - ] - return self._options + return [ + Option( + option.get_attribute('value'), + option.get_attribute('innerText'), + ) + for option in self.select_source.options + ] def select(self, label: str | None = None, value: str | None = None): if label: @@ -164,9 +144,6 @@ def __contains__(self, key: Any) -> bool: class ListElement(Element): _is_ordered: bool | None = None _items_sources: list[WebElement] | None = None - _items: list[Item] | None = None - _items_ids: list[str | None] | None = None - _items_labels: list[str | None] | None = None @property def items_sources(self) -> list[WebElement]: @@ -182,28 +159,24 @@ def is_ordered(self) -> bool: @property def items(self) -> list[Item]: - if self._items is None: - self._items = [ - Item(item.get_attribute('id'), item.get_attribute('innerText')) - for item in self.items_sources - ] - return self._items + return [ + Item(item.get_attribute('id'), item.get_attribute('innerText')) + for item in self.items_sources + ] @property def items_ids(self) -> list[str | None]: - if not self._items_ids: - self._items_ids = [ - item.get_attribute('ids') for item in self.items_sources - ] - return self._items_ids + return [ + item.get_attribute('ids') + for item in self.items_sources + ] @property def items_labels(self) -> list[str | None]: - if self._items_labels is None: - self._items_labels = [ - item.get_attribute('innerText') for item in self.items_sources - ] - return self._items_labels + return [ + item.get_attribute('innerText') + for item in self.items_sources + ] def click_in_item(self, label: str | None = None, id: str | None = None): if not (label or id): @@ -234,9 +207,13 @@ def __contains__(self, key: Any) -> bool: class ButtonElement(Element): + _is_link: bool | None = None + @property def is_link(self) -> bool: - return self.tag == 'a' + if self._is_link is None: + self._is_link = (self.tag == 'a') + return self._is_link @property def reference(self) -> str | None: @@ -264,9 +241,7 @@ def submit(self, button: ButtonElement | None = None): class TableElement(Element): - _headers: list[str | None] | None = None _headers_sources: list[WebElement] | None = None - _rows: list[list[str | None]] | None = None _rows_sources: list[WebElement] | None = None @property @@ -278,12 +253,10 @@ def headers_sources(self) -> list[WebElement]: @property def headers(self) -> list[str | None]: - if self._headers is None: - self._headers = [ - header.get_attribute('innerText') if header else None - for header in self.headers_sources - ] - return self._headers + return [ + header.get_attribute('innerText') if header else None + for header in self.headers_sources + ] @property def rows_sources(self) -> list[WebElement]: @@ -296,19 +269,17 @@ def rows_sources(self) -> list[WebElement]: @property def rows(self) -> list[list[str | None]]: - if self._rows is None: - rows_content = [] - for element in self.rows_sources: - rows_content.append( - [ - cell.get_attribute('innerText') - for cell in element.find_elements( - By.XPATH, './/td | .//th' - ) - ] - ) - self._rows = rows_content - return self._rows + rows_content = [] + for element in self.rows_sources: + rows_content.append( + [ + cell.get_attribute('innerText') + for cell in element.find_elements( + By.XPATH, './/td | .//th' + ) + ] + ) + return rows_content def column_values( self, name: str | None = None, index: int | None = None From 16d3d8318d5bfa2a01d077be06d5fc1e7e068c14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Mon, 22 Jul 2024 17:08:10 -0300 Subject: [PATCH 28/78] feat - keyboard shortcut --- fastrpa/core/keyboard.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/fastrpa/core/keyboard.py b/fastrpa/core/keyboard.py index 85d08a9..1105b3d 100644 --- a/fastrpa/core/keyboard.py +++ b/fastrpa/core/keyboard.py @@ -45,11 +45,20 @@ def key_code(self, key: str) -> str: return self.keys_mapping[key_name] elif key in self.keys_mapping.values(): return key + elif len(key) == 1: + return key raise KeyDoesNotExists(key) def press(self, key: str): self.actions.send_keys(self.key_code(key)) self.actions.perform() + def shortcut(self, *keys: str): + for key in keys: + self.actions.key_down(self.key_code(key)) + for key in keys[::-1]: + self.actions.key_up(self.key_code(key)) + self.actions.perform() + def __contains__(self, value: Any) -> bool: return self.has_key(value) or self.has_key(code=value) From c995fe170528aec53c9c701790ae366c470aceb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Fri, 26 Jul 2024 11:59:01 -0300 Subject: [PATCH 29/78] feat - new elements documentation --- README.md | 218 ++++++++++++++++++++++++++++++++++----- fastrpa/core/elements.py | 3 + 2 files changed, 196 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index a23775b..9d42935 100644 --- a/README.md +++ b/README.md @@ -100,20 +100,32 @@ You can access these abstractions by calling it from the `Web` object. ### Pressing keys -You can send simple pressing key events to the current page, by using the methods below. +You can send keyboard events to the current page, by using the methods below. ```python >>> app = FastRPA() >>> web = app.browse('https:...') -# You can press the following keys ->>> web.keyboad.esc() ->>> web.keyboad.tab() ->>> web.keyboad.enter() ->>> web.keyboad.backspace() ->>> web.keyboad.home() ->>> web.keyboad.page_up() ->>> web.keyboad.page_down() +# To send a simple key press event +>>> web.keyboad.press('control') +>>> web.keyboad.press('escape') +>>> web.keyboad.press('enter') + +# To send a keyboard shortcut event +>>> web.keyboad.shortcut('control', 'a') +>>> web.keyboad.shortcut('control', 'shift', 'c') + +# To see the available command keyboard keys +>>> web.keyboad.keys +['ADD', + 'ALT', + 'ARROW_DOWN', + 'ARROW_LEFT', + 'ARROW_RIGHT', + 'ARROW_UP', + 'BACKSPACE', + 'BACK_SPACE', + ... ``` ### Waiting for events @@ -177,6 +189,9 @@ None # Add a new cookie on the current domain >>> web.cookies.add('my_cookie', 'value', secure=False) Cookie(name='my_cookie', value='value', domain='...', path='/', secure=False, http_only=True, same_site='Strict') + +# Delete a cookie on the current domain +>>> web.cookies.delete('my_cookie') ``` ### Take screenshots and prints @@ -187,22 +202,26 @@ By default, all screenshot methods save the files in the current active director >>> app = FastRPA() >>> web = app.browse('https:...') -# Take a screenshot just containing the current viewport size ->>> web.screenshot.viewport() +# Get a PNG bytes content from the current viewport size +>>> web.screenshot.image +b'\x89PNG\r\n\x1a\n\x00\x00...' -# Take a screenshot just containing the current viewport size, and save the file in the specified path ->>> web.screenshot.viewport('/my/screenshot/path.png') +# Save a PNG file from the current viewport size +>>> web.screenshot.save_image() +>>> web.screenshot.save_image('/my/screenshot/path.png') -# Take a screenshot containing the complete page ->>> web.screenshot.full_page() +# Get a PNG bytes content from the complete page +>>> web.screenshot.full_page_image +b'\x89PNG\r\n\x1a\n\x00\x00...' -# Take a screenshot containing the complete page, and save the file in the specified path ->>> web.screenshot.full_page('/my/screenshot/path.png') +# Save a PNG file from the complete page +>>> web.screenshot.save_full_page() +>>> web.screenshot.save_full_page('/my/screenshot/path.png') ``` You can see examples of both screenshot methods below. -| Viewport screenshot | Full page screenshot | +| `web.screenshot.image` | `web.screenshot.full_page_image` | |-|-| | ![image](https://github.com/user-attachments/assets/ec5abfe0-5858-450a-a938-9fff58f89b86) | ![image](https://github.com/user-attachments/assets/606b73d7-52cb-4477-a328-89ba5b1563d1) | @@ -212,7 +231,22 @@ Comming soon... ### Running javascript -Comming soon... +To run javascript on the current page, you can use the following methods. + +```python +>>> app = FastRPA() +>>> web = app.browse('https:...') + +# To just evaluate a simple expression +>>> web.console.evaluate('2 + 2') +4 + +# To run complex and multi line scripts, use this +>>> web.console.run(['button = document.getElementById("myButton")', 'button.click()']) + +# To run a script file, use this +>>> web.console.run_script('/path/to/script.js') +``` ## Interacting with the page elements @@ -265,7 +299,11 @@ fastrpa.core.elements.Element # Get the element's class >>> element.css_class -'form form-styled' +['form', 'form-styled'] + +# Get the element's inline css +>>> element.css_inline +{'background-image': 'url("...")'} # Get the element's innerText or value >>> element.text @@ -348,7 +386,7 @@ Interactions with `select` tag. fastrpa.core.elements.SelectElement # Try to get a SelectElement and raise an ElementNotCompatible exception if the element isn't an select input ->>> my_select = web.file_input('//*[id="mySelect"]') +>>> my_select = web.select('//*[id="mySelect"]') >>> type(my_select) fastrpa.core.elements.SelectElement @@ -386,19 +424,149 @@ False ### Lists -Need to write. +Interactions with `ol` and `ul` tags. + +```python +# Gets the right element class for the xpath +>>> my_list = web.element('//*[id="myList"]') +>>> type(my_select) +fastrpa.core.elements.ListElement + +# Try to get a ListElement and raise an ElementNotCompatible exception if the element isn't an select input +>>> my_list = web.list('//*[id="myList"]') +>>> type(my_select) +fastrpa.core.elements.ListElement + +# Check if the list is ordered +>>> my_list.is_ordered +True + +# Get all items from the list +>>> my_list.items +[Item(id='1', label='Item 1'), + Item(id='2', label='Item 2')] + +# Get just the items ids +>>> my_list.items_ids +['1', '2'] + +# Get just the items labels +>>> my_list.items_labels +['Item 1', 'Item 2'] + +# Click in the item by label +>>> my_list.click_in_item('Item 1') + +# Click in the item by id +>>> my_list.click_in_item(id='1') + +# Check if an item exists, by label and value +>>> 'Item 3' in my_list +False + +# Check if an item exists, just by label +>>> my_list.has_item('Option 3') +False + +# Check if an item exists, just by id +>>> my_list.has_item(id='3') +False +``` ### Buttons and links -Need to write. +Interactions with `button` and `a` tags. + +```python +# Gets the right element class for the xpath +>>> my_button = web.element('//*[id="myButton"]') +>>> type(my_select) +fastrpa.core.elements.ButtonElement + +# Try to get a ButtonElement and raise an ElementNotCompatible exception if the element isn't an select input +>>> my_button = web.button('//*[id="myButton"]') +>>> type(my_select) +fastrpa.core.elements.ButtonElement + +# Check if the button is a link +>>> my_button.is_link +True + +# Get the link reference +>>> my_button.reference +'https://www.mysite.com/page' + +# Click in the button +>>> my_button.click() + +# Perform a double click in the button +>>> my_button.double_click() +``` ### Forms -Need to write. +Interactions with `form` tag. + +```python +# Gets the right element class for the xpath +>>> my_form = web.element('//*[id="myForm"]') +>>> type(my_select) +fastrpa.core.elements.FormElement + +# Try to get a FormElement and raise an ElementNotCompatible exception if the element isn't an select input +>>> my_form = web.button('//*[id="myForm"]') +>>> type(my_select) +fastrpa.core.elements.FormElement + +# Submit the form +>>> my_form.submit() + +# Submit the form by clicking in a button +>>> my_form_button = web.button('//*[id="formSubmit"]') +>>> my_form.submit(my_form_button) +``` ### Tables -Need to write. +Interactions with `table` tag. + +```python +# Gets the right element class for the xpath +>>> my_table = web.element('//*[id="myTable"]') +>>> type(my_select) +fastrpa.core.elements.TableElement + +# Try to get a TableElement and raise an ElementNotCompatible exception if the element isn't an select input +>>> my_table = web.button('//*[id="myTable"]') +>>> type(my_select) +fastrpa.core.elements.TableElement + +# Get the headers values +>>> my_table.headers +['Company', 'Contact', 'Country'] + +# Get the rows values +>>> my_table.rows +[['Alfreds Futterkiste', 'Maria Anders', 'Germany'], + ['Centro comercial Moctezuma', 'Francisco Chang', 'Mexico'], + ...] + +# Get all values from one column, by column name +>>> my_table.column_values('Company') +['Alfreds Futterkiste', + 'Centro comercial Moctezuma', + ...] + +# Get all values from one column, by column index +>>> my_table.column_values(index=0) +['Alfreds Futterkiste', + 'Centro comercial Moctezuma', + ...] + +# Check if a value exists in one of the table cells +>>> 'Cell content' in my_table +False +``` ### Medias diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index ab9ab75..d323e86 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -298,6 +298,9 @@ def has_content(self, value: str) -> bool: for cell in self.source.find_elements('.//td') ] return value in cells_content + + def __contains__(self, value: Any) -> bool: + return self.has_content(value) def print(self): print_table(self.headers, self.rows) From d89ff90eabb7681acdb1cf6460b00bee5b503dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Fri, 26 Jul 2024 12:03:05 -0300 Subject: [PATCH 30/78] feat - add table print to doc --- README.md | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9d42935..b55c9b2 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,7 @@ Interactions with `input` and `textarea` tags. >>> type(my_input) fastrpa.core.elements.InputElement -# Try to get an InputElement and raise an ElementNotCompatible exception if the element isn't an input +# Try to get an InputElement >>> my_input = web.input('//*[id="myInput"]') >>> type(my_input) fastrpa.core.elements.InputElement @@ -363,7 +363,7 @@ Interactions with `input` with attribute `type="file"`. >>> type(my_input) fastrpa.core.elements.FileInputElement -# Try to get a FileInputElement and raise an ElementNotCompatible exception if the element isn't an file input +# Try to get a FileInputElement >>> my_input = web.file_input('//*[id="myFileInput"]') >>> type(my_input) fastrpa.core.elements.FileInputElement @@ -385,7 +385,7 @@ Interactions with `select` tag. >>> type(my_select) fastrpa.core.elements.SelectElement -# Try to get a SelectElement and raise an ElementNotCompatible exception if the element isn't an select input +# Try to get a SelectElement >>> my_select = web.select('//*[id="mySelect"]') >>> type(my_select) fastrpa.core.elements.SelectElement @@ -432,7 +432,7 @@ Interactions with `ol` and `ul` tags. >>> type(my_select) fastrpa.core.elements.ListElement -# Try to get a ListElement and raise an ElementNotCompatible exception if the element isn't an select input +# Try to get a ListElement >>> my_list = web.list('//*[id="myList"]') >>> type(my_select) fastrpa.core.elements.ListElement @@ -483,7 +483,7 @@ Interactions with `button` and `a` tags. >>> type(my_select) fastrpa.core.elements.ButtonElement -# Try to get a ButtonElement and raise an ElementNotCompatible exception if the element isn't an select input +# Try to get a ButtonElement >>> my_button = web.button('//*[id="myButton"]') >>> type(my_select) fastrpa.core.elements.ButtonElement @@ -513,7 +513,7 @@ Interactions with `form` tag. >>> type(my_select) fastrpa.core.elements.FormElement -# Try to get a FormElement and raise an ElementNotCompatible exception if the element isn't an select input +# Try to get a FormElement >>> my_form = web.button('//*[id="myForm"]') >>> type(my_select) fastrpa.core.elements.FormElement @@ -536,7 +536,7 @@ Interactions with `table` tag. >>> type(my_select) fastrpa.core.elements.TableElement -# Try to get a TableElement and raise an ElementNotCompatible exception if the element isn't an select input +# Try to get a TableElement >>> my_table = web.button('//*[id="myTable"]') >>> type(my_select) fastrpa.core.elements.TableElement @@ -566,6 +566,19 @@ fastrpa.core.elements.TableElement # Check if a value exists in one of the table cells >>> 'Cell content' in my_table False + +# Print the table in console +>>> my_table.print() +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓ +┃ Company ┃ Contact ┃ Country ┃ +┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━┩ +│ Alfreds Futterkiste │ Maria Anders │ Germany │ +│ Centro comercial Moctezuma │ Francisco Chang │ Mexico │ +│ Ernst Handel │ Roland Mendel │ Austria │ +│ Island Trading │ Helen Bennett │ UK │ +│ Laughing Bacchus Winecellars │ Yoshi Tannamuri │ Canada │ +│ Magazzini Alimentari Riuniti │ Giovanni Rovelli │ Italy │ +└──────────────────────────────┴──────────────────┴─────────┘ ``` ### Medias From 6d9c580c035a479e1941d4b8f6cbe23e310b7682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Fri, 26 Jul 2024 12:17:01 -0300 Subject: [PATCH 31/78] feat - add form attributes --- README.md | 12 ++++++++++++ fastrpa/core/elements.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/README.md b/README.md index b55c9b2..3b7302b 100644 --- a/README.md +++ b/README.md @@ -518,6 +518,18 @@ fastrpa.core.elements.FormElement >>> type(my_select) fastrpa.core.elements.FormElement +# Get form method +>>> my_form.method +'POST' + +# Get form action +>>> my_form.action +'https://www.mysite.com/form' + +# Get form type +>>> my_form.type +'application/x-www-form-urlencoded' + # Submit the form >>> my_form.submit() diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index d323e86..3690543 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -233,6 +233,34 @@ def double_click(self): class FormElement(Element): + _method: str | None = None + _action: str | None = None + _type: str | None = None + + @property + def method(self) -> str: + if not self._method: + if gotten_method := self.attribute('method'): + self._method = gotten_method.upper() + else: + self._method = 'GET' + return self._method + + @property + def action(self) -> str | None: + if not self._action: + self._action = self.attribute('action') + return self._action + + @property + def type(self) -> str: + if not self._type: + if gotten_type := self.attribute('enctype'): + self._type = gotten_type + else: + self._type = 'application/x-www-form-urlencoded' + return self._type + def submit(self, button: ButtonElement | None = None): if not button: self.source.submit() From 89c9b67ccae643a291731ddf904e7e5a34f57d85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Fri, 26 Jul 2024 13:46:00 -0300 Subject: [PATCH 32/78] feat - tabs abstraction --- README.md | 38 ++++++++++++++++++++++++++++++- fastrpa/app.py | 6 +++++ fastrpa/core/tabs.py | 54 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 fastrpa/core/tabs.py diff --git a/README.md b/README.md index 3b7302b..a21538e 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,43 @@ You can see examples of both screenshot methods below. ### Manage and navigate through opened tabs -Comming soon... +To manage and navigate through browse tabs, use the following methods. + +```python +>>> app = FastRPA() +>>> web = app.browse('https:...') + +# Get opened tabs ids +>>> web.tabs.list +['AD9B396BF70C366D8A1FDE5450699D41', ...] + +# Get current tab id +>>> web.tabs.current +'AD9B396BF70C366D8A1FDE5450699D41' + +# Get current tab index +>>> web.tabs.current_index +0 + +# Get the opened tabs count +>>> web.tabs.count +5 + +# Get the opened tabs count +>>> len(web.tabs) +5 + +# Open a new tab, switch to it and return the new tab id +>>> web.tabs.new() +'AD9B396BF70C366D8A1FDE5450699D41' + +# Close the current tab +>>> web.tabs.close() + +# Check if a tab is opened +>>> 'AD9B396BF70C366D8A1FDE5450699D41' in web.tabs +True +``` ### Running javascript diff --git a/fastrpa/app.py b/fastrpa/app.py index 6198312..ea5532f 100644 --- a/fastrpa/app.py +++ b/fastrpa/app.py @@ -7,6 +7,7 @@ from fastrpa.core.console import Console from fastrpa.core.cookies import Cookies from fastrpa.core.screenshot import Screenshot +from fastrpa.core.tabs import Tabs from fastrpa.exceptions import ElementNotCompatible from fastrpa.settings import DEFAULT_TIMEOUT from fastrpa.core.elements import ( @@ -46,6 +47,7 @@ def __init__( self.screenshot = Screenshot(self.webdriver) self.cookies = Cookies(self.webdriver) self.console = Console(self.webdriver) + self.tabs = Tabs(self.webdriver) self.factory = ElementFactory(self.webdriver) @property @@ -55,6 +57,10 @@ def url(self) -> str: @property def domain(self) -> str: return get_domain(self.webdriver) + + @property + def title(self) -> str: + return self.webdriver.title def browse(self, url: str): self.webdriver.get(url) diff --git a/fastrpa/core/tabs.py b/fastrpa/core/tabs.py new file mode 100644 index 0000000..2f4f433 --- /dev/null +++ b/fastrpa/core/tabs.py @@ -0,0 +1,54 @@ +from typing import Any +from fastrpa.types import WebDriver + + +class Tabs: + + def __init__(self, webdriver: WebDriver): + self.webdriver = webdriver + + @property + def list(self) -> list[str]: + return self.webdriver.window_handles + + @property + def current(self) -> str: + return self.webdriver.current_window_handle + + @property + def current_index(self) -> int: + return self.list.index(self.current) + + @property + def count(self) -> int: + return len(self.list) + + def new(self) -> str: + self.webdriver.switch_to.new_window('tab') + return self.list[-1] + + def switch(self, tab: str | None = None, index: int | None = None): + if tab: + self.webdriver.switch_to.window(tab) + elif index is not None: + self.webdriver.switch_to.window(self.list[index]) + else: + raise ValueError('You must provide at least "tab" or "index"!') + + def close(self): + current_tab_index = self.current_index + if self.count >= 2: + self.webdriver.close() + self.switch(index=current_tab_index-1) + else: + self.new() + self.switch(index=current_tab_index) + self.webdriver.close() + self.switch(index=current_tab_index) + + def __len__(self) -> int: + return self.count + + def __contains__(self, value: Any) -> bool: + return value in self.list + \ No newline at end of file From 578d918f4f01a36455e5977b51b056613de9a5f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Fri, 26 Jul 2024 19:51:58 -0300 Subject: [PATCH 33/78] feat - xpath utils --- fastrpa/__init__.py | 10 ++++++++++ fastrpa/app.py | 25 ++++++++++++++++-------- fastrpa/core/console.py | 3 +-- fastrpa/core/cookies.py | 2 +- fastrpa/core/elements.py | 39 +++++++++++++++++++------------------- fastrpa/core/keyboard.py | 8 ++++---- fastrpa/core/screenshot.py | 38 ++++++++++++++++++------------------- fastrpa/core/tabs.py | 14 ++++++-------- fastrpa/xpath.py | 14 ++++++++++++++ 9 files changed, 92 insertions(+), 61 deletions(-) create mode 100644 fastrpa/xpath.py diff --git a/fastrpa/__init__.py b/fastrpa/__init__.py index 9696002..29d1150 100644 --- a/fastrpa/__init__.py +++ b/fastrpa/__init__.py @@ -9,6 +9,12 @@ FormElement, TableElement, ) +from fastrpa.xpath import ( + id_contains, + class_contains, + name_contains, + text_contains, +) from fastrpa.exceptions import ElementNotFound, ElementNotFoundAfterTime @@ -23,6 +29,10 @@ 'ButtonElement', 'FormElement', 'TableElement', + 'id_contains', + 'class_contains', + 'name_contains', + 'text_contains', 'ElementNotFound', 'ElementNotFoundAfterTime', ) diff --git a/fastrpa/app.py b/fastrpa/app.py index ea5532f..36274f5 100644 --- a/fastrpa/app.py +++ b/fastrpa/app.py @@ -1,5 +1,10 @@ from typing import Type, TypeVar, Union from selenium.webdriver import Remote, ChromeOptions +from selenium.webdriver.common.by import By +from selenium.common.exceptions import ( + NoSuchElementException, + StaleElementReferenceException, +) from fastrpa.commons import ( get_browser_options, get_domain, @@ -34,13 +39,10 @@ class Web: def __init__( self, - url: str, webdriver: WebDriver, timeout_seconds: int, ): - self.starter_url = url self.webdriver = webdriver - self.webdriver.get(self.starter_url) self.timeout_seconds = timeout_seconds self.keyboard = Keyboard(self.webdriver) self.timer = Timer(self.webdriver) @@ -57,7 +59,7 @@ def url(self) -> str: @property def domain(self) -> str: return get_domain(self.webdriver) - + @property def title(self) -> str: return self.webdriver.title @@ -68,8 +70,15 @@ def browse(self, url: str): def refresh(self): self.webdriver.refresh() - def reset(self): - self.webdriver.get(self.starter_url) + def is_interactive(self, xpath: str) -> bool: + try: + if element := self.webdriver.find_element(By.XPATH, xpath): + return element.is_displayed() + return False + except NoSuchElementException: + return False + except StaleElementReferenceException: + return False def element(self, xpath: str, wait: bool = True) -> GenericElement: if not wait: @@ -145,5 +154,5 @@ def webdriver(self) -> WebDriver: def __del__(self): self.webdriver.quit() - def browse(self, url: str) -> Web: - return Web(url, self.webdriver, self.timeout_seconds) + def web(self) -> Web: + return Web(self.webdriver, self.timeout_seconds) diff --git a/fastrpa/core/console.py b/fastrpa/core/console.py index b2f3d86..234af7f 100644 --- a/fastrpa/core/console.py +++ b/fastrpa/core/console.py @@ -3,7 +3,6 @@ class Console: - def __init__(self, webdriver: WebDriver) -> None: self.webdriver = webdriver @@ -12,7 +11,7 @@ def evaluate(self, code: str) -> Any: def run(self, lines: list[str]) -> Any: return self.webdriver.execute_script('\n'.join(lines)) - + def run_script(self, path: str) -> Any: with open(path, 'r') as script: return self.run(script.readlines()) diff --git a/fastrpa/core/cookies.py b/fastrpa/core/cookies.py index bbd7b06..f1c2d69 100644 --- a/fastrpa/core/cookies.py +++ b/fastrpa/core/cookies.py @@ -36,7 +36,7 @@ def add(self, name: str, value: str, secure: bool = False) -> Cookie: if added_cookie := self.webdriver.get_cookie(name): return Cookie.from_selenium(added_cookie) raise CookieNotAdded(name) - + def delete(self, name: str): self.webdriver.delete_cookie(name) diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index 3690543..0597257 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -3,6 +3,7 @@ from selenium.webdriver import ActionChains from selenium.webdriver.support.ui import Select from selenium.webdriver.remote.webelement import WebElement +from selenium.common.exceptions import StaleElementReferenceException from selenium.webdriver.common.by import By from fastrpa.commons import get_file_path, print_table @@ -39,7 +40,7 @@ def css_class(self) -> list[str] | None: if classes := self.attribute('class'): return classes.split(' ') return [] - + @property def css_inline(self) -> dict[str, str]: to_return = {} @@ -62,11 +63,24 @@ def text(self) -> str | None: def is_visible(self) -> bool: return self.source.is_displayed() + @property + def is_stale(self) -> bool: + try: + self.is_visible + return False + except StaleElementReferenceException: + return True + def focus(self): self.actions.scroll_to_element(self.source) self.actions.move_to_element(self.source) self.actions.perform() + def click(self): + self.focus() + self.actions.click(self.source) + self.actions.perform() + def check(self, attribute: str, value: str) -> bool: return self.attribute(attribute) == value @@ -166,17 +180,11 @@ def items(self) -> list[Item]: @property def items_ids(self) -> list[str | None]: - return [ - item.get_attribute('ids') - for item in self.items_sources - ] + return [item.get_attribute('ids') for item in self.items_sources] @property def items_labels(self) -> list[str | None]: - return [ - item.get_attribute('innerText') - for item in self.items_sources - ] + return [item.get_attribute('innerText') for item in self.items_sources] def click_in_item(self, label: str | None = None, id: str | None = None): if not (label or id): @@ -212,7 +220,7 @@ class ButtonElement(Element): @property def is_link(self) -> bool: if self._is_link is None: - self._is_link = (self.tag == 'a') + self._is_link = self.tag == 'a' return self._is_link @property @@ -221,11 +229,6 @@ def reference(self) -> str | None: return self.attribute('href') return None - def click(self): - self.actions.move_to_element(self.source) - self.actions.click(self.source) - self.actions.perform() - def double_click(self): self.actions.move_to_element(self.source) self.actions.double_click(self.source) @@ -302,9 +305,7 @@ def rows(self) -> list[list[str | None]]: rows_content.append( [ cell.get_attribute('innerText') - for cell in element.find_elements( - By.XPATH, './/td | .//th' - ) + for cell in element.find_elements(By.XPATH, './/td | .//th') ] ) return rows_content @@ -326,7 +327,7 @@ def has_content(self, value: str) -> bool: for cell in self.source.find_elements('.//td') ] return value in cells_content - + def __contains__(self, value: Any) -> bool: return self.has_content(value) diff --git a/fastrpa/core/keyboard.py b/fastrpa/core/keyboard.py index 1105b3d..8aee0e9 100644 --- a/fastrpa/core/keyboard.py +++ b/fastrpa/core/keyboard.py @@ -22,23 +22,23 @@ def keys_mapping(self) -> dict[str, str]: if not attr.startswith('_') } return self._keys_mapping - + @property def keys(self) -> list[str]: if self._keys is None: self._keys = list(self.keys_mapping.keys()) return self._keys - + def key_name(self, name: str) -> str: return name.upper().replace(' ', '_') - + def has_key(self, name: str | None = None, code: str | None = None) -> bool: if name: return self.key_name(name) in self.keys elif code: return code in self.keys_mapping.values() raise ValueError('You must provide at least "name" or "code"!') - + def key_code(self, key: str) -> str: key_name = self.key_name(key) if key_name in self.keys: diff --git a/fastrpa/core/screenshot.py b/fastrpa/core/screenshot.py index fb7e957..d838d5f 100644 --- a/fastrpa/core/screenshot.py +++ b/fastrpa/core/screenshot.py @@ -12,15 +12,13 @@ def _file_path(self, path: str | None = None) -> str: if path is None: path = f'{datetime.now().isoformat()}.png' return path - - def _restore_window(self, size: dict[str, int], position: dict[str, int], is_maximized: bool): + + def _restore_window( + self, size: dict[str, int], position: dict[str, int], is_maximized: bool + ): if not is_maximized: - self.webdriver.set_window_size( - size['width'], size['height'] - ) - self.webdriver.set_window_position( - position['x'], position['y'] - ) + self.webdriver.set_window_size(size['width'], size['height']) + self.webdriver.set_window_position(position['x'], position['y']) else: self.webdriver.maximize_window() @@ -35,18 +33,22 @@ def max_height(self) -> int: return self.webdriver.execute_script( 'return document.documentElement.scrollHeight + (window.innerHeight * 0.2)' ) - + @property def is_maximized(self) -> bool: window_size = self.webdriver.get_window_size() - screen_width = self.webdriver.execute_script("return screen.availWidth") - screen_height = self.webdriver.execute_script("return screen.availHeight") - return (window_size['width'] == screen_width) and (window_size['height'] == screen_height) + screen_width = self.webdriver.execute_script('return screen.availWidth') + screen_height = self.webdriver.execute_script( + 'return screen.availHeight' + ) + return (window_size['width'] == screen_width) and ( + window_size['height'] == screen_height + ) @property def image(self) -> bytes: return self.webdriver.get_screenshot_as_png() - + @property def full_page_image(self) -> bytes: starter_position = self.webdriver.get_window_position() @@ -63,7 +65,7 @@ def full_page_image(self) -> bytes: self._restore_window( size=starter_size, position=starter_position, - is_maximized=starter_is_maximized + is_maximized=starter_is_maximized, ) return image @@ -71,7 +73,7 @@ def save_image(self, path: str | None = None): success = self.webdriver.save_screenshot(self._file_path(path)) if not success: raise ScreenshotNotTaken('image', self.webdriver.current_url) - + def save_full_page_image(self, path: str | None = None): starter_size = self.webdriver.get_window_size() starter_position = self.webdriver.get_window_position() @@ -80,9 +82,7 @@ def save_full_page_image(self, path: str | None = None): try: self.webdriver.set_window_size(self.max_width, self.max_height) element = self.webdriver.find_element(By.XPATH, '//body') - success = element.screenshot( - self._file_path(path) - ) + success = element.screenshot(self._file_path(path)) if not success: raise ScreenshotNotTaken('image', self.webdriver.current_url) @@ -91,5 +91,5 @@ def save_full_page_image(self, path: str | None = None): self._restore_window( size=starter_size, position=starter_position, - is_maximized=starter_is_maximized + is_maximized=starter_is_maximized, ) diff --git a/fastrpa/core/tabs.py b/fastrpa/core/tabs.py index 2f4f433..dc8180c 100644 --- a/fastrpa/core/tabs.py +++ b/fastrpa/core/tabs.py @@ -3,18 +3,17 @@ class Tabs: - def __init__(self, webdriver: WebDriver): self.webdriver = webdriver - + @property def list(self) -> list[str]: return self.webdriver.window_handles - + @property def current(self) -> str: return self.webdriver.current_window_handle - + @property def current_index(self) -> int: return self.list.index(self.current) @@ -22,7 +21,7 @@ def current_index(self) -> int: @property def count(self) -> int: return len(self.list) - + def new(self) -> str: self.webdriver.switch_to.new_window('tab') return self.list[-1] @@ -39,7 +38,7 @@ def close(self): current_tab_index = self.current_index if self.count >= 2: self.webdriver.close() - self.switch(index=current_tab_index-1) + self.switch(index=current_tab_index - 1) else: self.new() self.switch(index=current_tab_index) @@ -48,7 +47,6 @@ def close(self): def __len__(self) -> int: return self.count - + def __contains__(self, value: Any) -> bool: return value in self.list - \ No newline at end of file diff --git a/fastrpa/xpath.py b/fastrpa/xpath.py new file mode 100644 index 0000000..aabf9de --- /dev/null +++ b/fastrpa/xpath.py @@ -0,0 +1,14 @@ +def id_contains(value: str, child: str = '') -> str: + return f'//*[contains(@id, "{value}")]{child}' + + +def class_contains(value: str, child: str = '') -> str: + return f'//*[contains(@class, "{value}")]{child}' + + +def name_contains(value: str, child: str = '') -> str: + return f'//*[contains(@name, "{value}")]{child}' + + +def text_contains(value: str, child: str = '') -> str: + return f'//*[contains(text(), "{value}")]{child}' From e4d239d9cd1b2c9f7fa910d70d81e4caffddba3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Mon, 29 Jul 2024 15:08:11 -0300 Subject: [PATCH 34/78] feat - new wait interface --- fastrpa/app.py | 18 +++++----- fastrpa/core/timer.py | 33 ----------------- fastrpa/core/wait.py | 84 +++++++++++++++++++++++++++++++++++++++++++ fastrpa/factory.py | 12 +++---- fastrpa/settings.py | 2 +- 5 files changed, 98 insertions(+), 51 deletions(-) delete mode 100644 fastrpa/core/timer.py create mode 100644 fastrpa/core/wait.py diff --git a/fastrpa/app.py b/fastrpa/app.py index 36274f5..3baa762 100644 --- a/fastrpa/app.py +++ b/fastrpa/app.py @@ -14,7 +14,7 @@ from fastrpa.core.screenshot import Screenshot from fastrpa.core.tabs import Tabs from fastrpa.exceptions import ElementNotCompatible -from fastrpa.settings import DEFAULT_TIMEOUT +from fastrpa.settings import TIMEOUT from fastrpa.core.elements import ( Element, InputElement, @@ -26,7 +26,7 @@ SelectElement, ) -from fastrpa.core.timer import Timer +from fastrpa.core.wait import Wait from fastrpa.core.keyboard import Keyboard from fastrpa.factory import ElementFactory from fastrpa.types import BrowserOptions, BrowserOptionsClass, WebDriver @@ -40,12 +40,12 @@ class Web: def __init__( self, webdriver: WebDriver, - timeout_seconds: int, + timeout: int, ): self.webdriver = webdriver - self.timeout_seconds = timeout_seconds + self.timeout = timeout self.keyboard = Keyboard(self.webdriver) - self.timer = Timer(self.webdriver) + self.wait = Wait(self.webdriver, timeout) self.screenshot = Screenshot(self.webdriver) self.cookies = Cookies(self.webdriver) self.console = Console(self.webdriver) @@ -83,7 +83,7 @@ def is_interactive(self, xpath: str) -> bool: def element(self, xpath: str, wait: bool = True) -> GenericElement: if not wait: return self.factory.get(xpath) - return self.factory.get_when_available(xpath, self.timeout_seconds) + return self.factory.get_when_available(xpath, self.timeout) def elements(self, xpath: str) -> list[GenericElement]: return self.factory.get_many(xpath) @@ -126,12 +126,12 @@ def __init__( webdriver: WebDriver | None = None, options_class: BrowserOptionsClass = ChromeOptions, browser_arguments: list[str] | None = None, - timeout_seconds: int = DEFAULT_TIMEOUT, + timeout: int = TIMEOUT, ): self._browser_options: BrowserOptions | None = None self._webdriver = webdriver self._options_class = options_class - self.timeout_seconds = timeout_seconds + self.timeout = timeout if browser_arguments: self.browser_arguments = browser_arguments @@ -155,4 +155,4 @@ def __del__(self): self.webdriver.quit() def web(self) -> Web: - return Web(self.webdriver, self.timeout_seconds) + return Web(self.webdriver, self.timeout) diff --git a/fastrpa/core/timer.py b/fastrpa/core/timer.py deleted file mode 100644 index 0d93139..0000000 --- a/fastrpa/core/timer.py +++ /dev/null @@ -1,33 +0,0 @@ -from time import sleep - -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions -from selenium.webdriver.support.ui import WebDriverWait - -from fastrpa.settings import DEFAULT_TIMEOUT -from fastrpa.types import WebDriver - - -class Timer: - def __init__(self, webdriver: WebDriver): - self.webdriver = webdriver - - def wait(self, timeout_seconds: int = DEFAULT_TIMEOUT) -> WebDriverWait: - return WebDriverWait(self.webdriver, timeout_seconds) - - def wait_until_hide( - self, xpath: str, timeout_seconds: int = DEFAULT_TIMEOUT - ): - self.wait(timeout_seconds).until_not( - expected_conditions.element_to_be_clickable((By.XPATH, xpath)) - ) - - def wait_until_present( - self, xpath: str, timeout_seconds: int = DEFAULT_TIMEOUT - ): - self.wait(timeout_seconds).until( - expected_conditions.element_to_be_clickable((By.XPATH, xpath)) - ) - - def wait_seconds(self, seconds: float): - sleep(seconds) diff --git a/fastrpa/core/wait.py b/fastrpa/core/wait.py new file mode 100644 index 0000000..3572e5b --- /dev/null +++ b/fastrpa/core/wait.py @@ -0,0 +1,84 @@ +from time import sleep + +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.ui import WebDriverWait + +from fastrpa.types import WebDriver + + +class Wait: + def __init__(self, webdriver: WebDriver, timeout: float): + self._timeout = timeout + self.webdriver = webdriver + + def get_timeout(self, value: float | None) -> float: + if value is not None: + return value + return self._timeout + + def source(self, timeout: float | None = None) -> WebDriverWait: + return WebDriverWait(self.webdriver, self.get_timeout(timeout)) + + def seconds(self, seconds: float): + sleep(seconds) + + def is_present(self, xpath: str, timeout: float | None = None): + self.source(self.get_timeout(timeout)).until( + EC.presence_of_element_located((By.XPATH, xpath)) + ) + + def not_is_present(self, xpath: str, timeout: float | None = None): + self.source(self.get_timeout(timeout)).until_not( + EC.presence_of_element_located((By.XPATH, xpath)) + ) + + def is_clickable(self, xpath: str, timeout: float | None = None): + self.source(self.get_timeout(timeout)).until( + EC.element_to_be_clickable((By.XPATH, xpath)) + ) + + def not_is_clickable(self, xpath: str, timeout: float | None = None): + self.source(self.get_timeout(timeout)).until_not( + EC.element_to_be_clickable((By.XPATH, xpath)) + ) + + def is_hidden(self, xpath: str, timeout: float | None = None): + self.source(self.get_timeout(timeout)).until( + EC.invisibility_of_element_located((By.XPATH, xpath)) + ) + + def not_is_hidden(self, xpath: str, timeout: float | None = None): + self.source(self.get_timeout(timeout)).until_not( + EC.invisibility_of_element_located((By.XPATH, xpath)) + ) + + def contains_text(self, xpath: str, text: str, timeout: float | None = None): + self.source(self.get_timeout(timeout)).until( + EC.text_to_be_present_in_element((By.XPATH, xpath), text) + ) + + def not_contains_text(self, xpath: str, text: str, timeout: float | None = None): + self.source(self.get_timeout(timeout)).until_not( + EC.text_to_be_present_in_element((By.XPATH, xpath), text) + ) + + def url_contains(self, sub_url: str, timeout: float | None = None): + self.source(self.get_timeout(timeout)).until( + EC.url_contains(sub_url) + ) + + def not_url_contains(self, sub_url: str, timeout: float | None = None): + self.source(self.get_timeout(timeout)).until_not( + EC.url_contains(sub_url) + ) + + def title_contains(self, sub_title: str, timeout: float | None = None): + self.source(self.get_timeout(timeout)).until( + EC.title_contains(sub_title) + ) + + def not_title_contains(self, sub_title: str, timeout: float | None = None): + self.source(self.get_timeout(timeout)).until_not( + EC.title_contains(sub_title) + ) diff --git a/fastrpa/factory.py b/fastrpa/factory.py index b981058..031ef76 100644 --- a/fastrpa/factory.py +++ b/fastrpa/factory.py @@ -16,7 +16,7 @@ TableElement, ) from fastrpa.exceptions import ElementNotFound, ElementNotFoundAfterTime -from fastrpa.settings import DEFAULT_TIMEOUT +from fastrpa.settings import TIMEOUT from fastrpa.types import WebDriver @@ -53,13 +53,9 @@ def element_class(self, element: WebElement) -> Type[Element]: return Element - def get_when_available( - self, xpath: str, timeout_seconds: int = DEFAULT_TIMEOUT - ) -> Element: + def get_when_available(self, xpath: str, timeout: int = TIMEOUT) -> Element: try: - selenium_element = WebDriverWait( - self.webdriver, timeout_seconds - ).until( + selenium_element = WebDriverWait(self.webdriver, timeout).until( expected_conditions.presence_of_element_located( (By.XPATH, xpath) ) @@ -68,7 +64,7 @@ def get_when_available( return element_class(selenium_element, self.webdriver) except TimeoutException: - raise ElementNotFoundAfterTime(xpath, timeout_seconds) + raise ElementNotFoundAfterTime(xpath, timeout) def get(self, xpath: str) -> Element: try: diff --git a/fastrpa/settings.py b/fastrpa/settings.py index bf30974..49ef147 100644 --- a/fastrpa/settings.py +++ b/fastrpa/settings.py @@ -1 +1 @@ -DEFAULT_TIMEOUT = 15 +TIMEOUT = 15 From a76b767faa7f17943311c5a781575032b38f6fb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Mon, 29 Jul 2024 15:27:54 -0300 Subject: [PATCH 35/78] refact - get elements on-demand --- fastrpa/core/elements.py | 62 +++++++++++----------------------------- fastrpa/xpath.py | 24 +++++++++++++--- 2 files changed, 37 insertions(+), 49 deletions(-) diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index 0597257..c41044c 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -3,7 +3,6 @@ from selenium.webdriver import ActionChains from selenium.webdriver.support.ui import Select from selenium.webdriver.remote.webelement import WebElement -from selenium.common.exceptions import StaleElementReferenceException from selenium.webdriver.common.by import By from fastrpa.commons import get_file_path, print_table @@ -12,28 +11,26 @@ class Element: - _tag: str | None = None - _id: str | None = None - def __init__(self, source: WebElement, webdriver: WebDriver) -> None: - self.source = source + def __init__(self, xpath: str, webdriver: WebDriver) -> None: + self.xpath = xpath self.webdriver = webdriver self.actions = ActionChains(self.webdriver) + @property + def source(self) -> WebElement: + return self.webdriver.find_element(By.XPATH, self.xpath) + def attribute(self, name: str) -> str | None: return self.source.get_attribute(name) @property def tag(self) -> str: - if self._tag is None: - self._tag = self.source.tag_name - return self._tag + return self.source.tag_name @property def id(self) -> str | None: - if self._id is None: - self._id = self.attribute('id') - return self._id + return self.attribute('id') @property def css_class(self) -> list[str] | None: @@ -63,14 +60,6 @@ def text(self) -> str | None: def is_visible(self) -> bool: return self.source.is_displayed() - @property - def is_stale(self) -> bool: - try: - self.is_visible - return False - except StaleElementReferenceException: - return True - def focus(self): self.actions.scroll_to_element(self.source) self.actions.move_to_element(self.source) @@ -156,7 +145,6 @@ def __contains__(self, key: Any) -> bool: class ListElement(Element): - _is_ordered: bool | None = None _items_sources: list[WebElement] | None = None @property @@ -167,9 +155,7 @@ def items_sources(self) -> list[WebElement]: @property def is_ordered(self) -> bool: - if self._is_ordered is None: - self._is_ordered = self.tag == 'ol' - return self._is_ordered + return self.tag == 'ol' @property def items(self) -> list[Item]: @@ -215,13 +201,10 @@ def __contains__(self, key: Any) -> bool: class ButtonElement(Element): - _is_link: bool | None = None @property def is_link(self) -> bool: - if self._is_link is None: - self._is_link = self.tag == 'a' - return self._is_link + return self.tag == 'a' @property def reference(self) -> str | None: @@ -236,33 +219,22 @@ def double_click(self): class FormElement(Element): - _method: str | None = None - _action: str | None = None - _type: str | None = None @property def method(self) -> str: - if not self._method: - if gotten_method := self.attribute('method'): - self._method = gotten_method.upper() - else: - self._method = 'GET' - return self._method + if gotten_method := self.attribute('method'): + return gotten_method.upper() + return 'GET' @property def action(self) -> str | None: - if not self._action: - self._action = self.attribute('action') - return self._action + return self.attribute('action') @property def type(self) -> str: - if not self._type: - if gotten_type := self.attribute('enctype'): - self._type = gotten_type - else: - self._type = 'application/x-www-form-urlencoded' - return self._type + if gotten_type := self.attribute('enctype'): + return gotten_type + return 'application/x-www-form-urlencoded' def submit(self, button: ButtonElement | None = None): if not button: diff --git a/fastrpa/xpath.py b/fastrpa/xpath.py index aabf9de..587c29b 100644 --- a/fastrpa/xpath.py +++ b/fastrpa/xpath.py @@ -1,14 +1,30 @@ + +def attribute_contains(element: str, attribute: str, value: str, child: str = '') -> str: + return f'//{element}[contains({attribute}, "{value}")]{child}' + +def attribute_equals(element: str, attribute: str, value: str, child: str = '') -> str: + return f'//{element}[{attribute}, "{value}"]{child}' + def id_contains(value: str, child: str = '') -> str: - return f'//*[contains(@id, "{value}")]{child}' + return attribute_contains('*', '@id', value, child) +def id_equals(value: str, child: str = '') -> str: + return attribute_equals('*', '@id', value, child) def class_contains(value: str, child: str = '') -> str: - return f'//*[contains(@class, "{value}")]{child}' + return attribute_contains('*', '@class', value, child) +def class_equals(value: str, child: str = '') -> str: + return attribute_equals('*', '@class', value, child) def name_contains(value: str, child: str = '') -> str: - return f'//*[contains(@name, "{value}")]{child}' + return attribute_contains('*', '@name', value, child) +def name_equals(value: str, child: str = '') -> str: + return attribute_equals('*', '@name', value, child) def text_contains(value: str, child: str = '') -> str: - return f'//*[contains(text(), "{value}")]{child}' + return attribute_contains('*', 'text()', value, child) + +def text_equals(value: str, child: str = '') -> str: + return attribute_equals('*', 'text()', value, child) From a2698f7c0ffc93e9f67f0b9a3ff0f55a4bd6f904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Mon, 29 Jul 2024 15:33:26 -0300 Subject: [PATCH 36/78] fix - elements factory --- fastrpa/factory.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/fastrpa/factory.py b/fastrpa/factory.py index 031ef76..d9829a8 100644 --- a/fastrpa/factory.py +++ b/fastrpa/factory.py @@ -39,7 +39,7 @@ def element_class(self, element: WebElement) -> Type[Element]: ): return FileInputElement - elif element.tag_name == 'input': + elif element.tag_name in ['input', 'textarea']: return InputElement elif element.tag_name == 'select': @@ -61,7 +61,7 @@ def get_when_available(self, xpath: str, timeout: int = TIMEOUT) -> Element: ) ) element_class = self.element_class(selenium_element) - return element_class(selenium_element, self.webdriver) + return element_class(xpath, self.webdriver) except TimeoutException: raise ElementNotFoundAfterTime(xpath, timeout) @@ -70,7 +70,7 @@ def get(self, xpath: str) -> Element: try: selenium_element = self.webdriver.find_element(By.XPATH, xpath) element_class = self.element_class(selenium_element) - return element_class(selenium_element, self.webdriver) + return element_class(xpath, self.webdriver) except NoSuchElementException: raise ElementNotFound(xpath) @@ -84,7 +84,7 @@ def get_many(self, xpath: str) -> list[Element]: ): element_class = self.element_class(selenium_element) elements_to_return.append( - element_class(selenium_element, self.webdriver) + element_class(xpath, self.webdriver) ) return elements_to_return From 6d77c23b8ffbf9fc9769436600601a88683713ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Mon, 29 Jul 2024 16:38:55 -0300 Subject: [PATCH 37/78] feat - ensure_element decorator --- fastrpa/core/elements.py | 3 --- fastrpa/core/wait.py | 16 +++++++++------- fastrpa/decorators.py | 14 ++++++++++++++ fastrpa/factory.py | 9 ++++++--- fastrpa/xpath.py | 18 +++++++++++++++--- 5 files changed, 44 insertions(+), 16 deletions(-) create mode 100644 fastrpa/decorators.py diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index c41044c..9ecc693 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -11,7 +11,6 @@ class Element: - def __init__(self, xpath: str, webdriver: WebDriver) -> None: self.xpath = xpath self.webdriver = webdriver @@ -201,7 +200,6 @@ def __contains__(self, key: Any) -> bool: class ButtonElement(Element): - @property def is_link(self) -> bool: return self.tag == 'a' @@ -219,7 +217,6 @@ def double_click(self): class FormElement(Element): - @property def method(self) -> str: if gotten_method := self.attribute('method'): diff --git a/fastrpa/core/wait.py b/fastrpa/core/wait.py index 3572e5b..d54c58d 100644 --- a/fastrpa/core/wait.py +++ b/fastrpa/core/wait.py @@ -19,7 +19,7 @@ def get_timeout(self, value: float | None) -> float: def source(self, timeout: float | None = None) -> WebDriverWait: return WebDriverWait(self.webdriver, self.get_timeout(timeout)) - + def seconds(self, seconds: float): sleep(seconds) @@ -53,20 +53,22 @@ def not_is_hidden(self, xpath: str, timeout: float | None = None): EC.invisibility_of_element_located((By.XPATH, xpath)) ) - def contains_text(self, xpath: str, text: str, timeout: float | None = None): + def contains_text( + self, xpath: str, text: str, timeout: float | None = None + ): self.source(self.get_timeout(timeout)).until( EC.text_to_be_present_in_element((By.XPATH, xpath), text) ) - def not_contains_text(self, xpath: str, text: str, timeout: float | None = None): + def not_contains_text( + self, xpath: str, text: str, timeout: float | None = None + ): self.source(self.get_timeout(timeout)).until_not( EC.text_to_be_present_in_element((By.XPATH, xpath), text) ) - + def url_contains(self, sub_url: str, timeout: float | None = None): - self.source(self.get_timeout(timeout)).until( - EC.url_contains(sub_url) - ) + self.source(self.get_timeout(timeout)).until(EC.url_contains(sub_url)) def not_url_contains(self, sub_url: str, timeout: float | None = None): self.source(self.get_timeout(timeout)).until_not( diff --git a/fastrpa/decorators.py b/fastrpa/decorators.py new file mode 100644 index 0000000..601ed27 --- /dev/null +++ b/fastrpa/decorators.py @@ -0,0 +1,14 @@ +from typing import Callable +from selenium.common.exceptions import StaleElementReferenceException + + +def ensure_element(func: Callable, max_attempts: int = 3): + def wrapper(*args, **kwargs): + attempt = 0 + while attempt < max_attempts: + try: + return func(*args, **kwargs) + except StaleElementReferenceException: + attempt += 1 + + return wrapper diff --git a/fastrpa/factory.py b/fastrpa/factory.py index d9829a8..3035ba9 100644 --- a/fastrpa/factory.py +++ b/fastrpa/factory.py @@ -1,3 +1,4 @@ +import time from typing import Type from selenium.common.exceptions import TimeoutException, NoSuchElementException from selenium.webdriver.common.by import By @@ -15,6 +16,7 @@ SelectElement, TableElement, ) +from fastrpa.decorators import ensure_element from fastrpa.exceptions import ElementNotFound, ElementNotFoundAfterTime from fastrpa.settings import TIMEOUT from fastrpa.types import WebDriver @@ -53,6 +55,7 @@ def element_class(self, element: WebElement) -> Type[Element]: return Element + @ensure_element def get_when_available(self, xpath: str, timeout: int = TIMEOUT) -> Element: try: selenium_element = WebDriverWait(self.webdriver, timeout).until( @@ -66,6 +69,7 @@ def get_when_available(self, xpath: str, timeout: int = TIMEOUT) -> Element: except TimeoutException: raise ElementNotFoundAfterTime(xpath, timeout) + @ensure_element def get(self, xpath: str) -> Element: try: selenium_element = self.webdriver.find_element(By.XPATH, xpath) @@ -75,6 +79,7 @@ def get(self, xpath: str) -> Element: except NoSuchElementException: raise ElementNotFound(xpath) + @ensure_element def get_many(self, xpath: str) -> list[Element]: elements_to_return = [] @@ -83,9 +88,7 @@ def get_many(self, xpath: str) -> list[Element]: By.XPATH, xpath ): element_class = self.element_class(selenium_element) - elements_to_return.append( - element_class(xpath, self.webdriver) - ) + elements_to_return.append(element_class(xpath, self.webdriver)) return elements_to_return diff --git a/fastrpa/xpath.py b/fastrpa/xpath.py index 587c29b..c75f9fc 100644 --- a/fastrpa/xpath.py +++ b/fastrpa/xpath.py @@ -1,30 +1,42 @@ - -def attribute_contains(element: str, attribute: str, value: str, child: str = '') -> str: +def attribute_contains( + element: str, attribute: str, value: str, child: str = '' +) -> str: return f'//{element}[contains({attribute}, "{value}")]{child}' -def attribute_equals(element: str, attribute: str, value: str, child: str = '') -> str: + +def attribute_equals( + element: str, attribute: str, value: str, child: str = '' +) -> str: return f'//{element}[{attribute}, "{value}"]{child}' + def id_contains(value: str, child: str = '') -> str: return attribute_contains('*', '@id', value, child) + def id_equals(value: str, child: str = '') -> str: return attribute_equals('*', '@id', value, child) + def class_contains(value: str, child: str = '') -> str: return attribute_contains('*', '@class', value, child) + def class_equals(value: str, child: str = '') -> str: return attribute_equals('*', '@class', value, child) + def name_contains(value: str, child: str = '') -> str: return attribute_contains('*', '@name', value, child) + def name_equals(value: str, child: str = '') -> str: return attribute_equals('*', '@name', value, child) + def text_contains(value: str, child: str = '') -> str: return attribute_contains('*', 'text()', value, child) + def text_equals(value: str, child: str = '') -> str: return attribute_equals('*', 'text()', value, child) From a3fc34d6e78af9719d4aa9a842038dc347d16b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Mon, 29 Jul 2024 16:39:15 -0300 Subject: [PATCH 38/78] fix - remove time import --- fastrpa/factory.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fastrpa/factory.py b/fastrpa/factory.py index 3035ba9..636676a 100644 --- a/fastrpa/factory.py +++ b/fastrpa/factory.py @@ -1,4 +1,3 @@ -import time from typing import Type from selenium.common.exceptions import TimeoutException, NoSuchElementException from selenium.webdriver.common.by import By From ff82dd51f8bd8d15dd2bb7f57c589c8e32ba0739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Mon, 29 Jul 2024 16:58:06 -0300 Subject: [PATCH 39/78] refact - removing TIMEOUT const --- fastrpa/__init__.py | 13 ----- fastrpa/app.py | 13 +++-- fastrpa/core/cookies.py | 5 +- fastrpa/core/elements.py | 5 +- .../{factory.py => core/elements_factory.py} | 16 +++--- fastrpa/core/wait.py | 28 +++++----- fastrpa/dataclasses.py | 48 ----------------- fastrpa/decorators.py | 14 ----- fastrpa/exceptions.py | 2 +- fastrpa/settings.py | 1 - fastrpa/types.py | 52 ++++++++++++++++--- fastrpa/{commons.py => utils.py} | 15 +++++- 12 files changed, 94 insertions(+), 118 deletions(-) rename fastrpa/{factory.py => core/elements_factory.py} (86%) delete mode 100644 fastrpa/dataclasses.py delete mode 100644 fastrpa/decorators.py delete mode 100644 fastrpa/settings.py rename fastrpa/{commons.py => utils.py} (74%) diff --git a/fastrpa/__init__.py b/fastrpa/__init__.py index 29d1150..1bc5e04 100644 --- a/fastrpa/__init__.py +++ b/fastrpa/__init__.py @@ -9,13 +9,6 @@ FormElement, TableElement, ) -from fastrpa.xpath import ( - id_contains, - class_contains, - name_contains, - text_contains, -) -from fastrpa.exceptions import ElementNotFound, ElementNotFoundAfterTime __all__ = ( @@ -29,10 +22,4 @@ 'ButtonElement', 'FormElement', 'TableElement', - 'id_contains', - 'class_contains', - 'name_contains', - 'text_contains', - 'ElementNotFound', - 'ElementNotFoundAfterTime', ) diff --git a/fastrpa/app.py b/fastrpa/app.py index 3baa762..9086900 100644 --- a/fastrpa/app.py +++ b/fastrpa/app.py @@ -5,7 +5,7 @@ NoSuchElementException, StaleElementReferenceException, ) -from fastrpa.commons import ( +from fastrpa.utils import ( get_browser_options, get_domain, ) @@ -14,7 +14,6 @@ from fastrpa.core.screenshot import Screenshot from fastrpa.core.tabs import Tabs from fastrpa.exceptions import ElementNotCompatible -from fastrpa.settings import TIMEOUT from fastrpa.core.elements import ( Element, InputElement, @@ -28,7 +27,7 @@ from fastrpa.core.wait import Wait from fastrpa.core.keyboard import Keyboard -from fastrpa.factory import ElementFactory +from fastrpa.core.elements_factory import ElementsFactory from fastrpa.types import BrowserOptions, BrowserOptionsClass, WebDriver @@ -45,12 +44,12 @@ def __init__( self.webdriver = webdriver self.timeout = timeout self.keyboard = Keyboard(self.webdriver) - self.wait = Wait(self.webdriver, timeout) self.screenshot = Screenshot(self.webdriver) self.cookies = Cookies(self.webdriver) self.console = Console(self.webdriver) self.tabs = Tabs(self.webdriver) - self.factory = ElementFactory(self.webdriver) + self.wait = Wait(self.webdriver, self.timeout) + self.factory = ElementsFactory(self.webdriver, self.timeout) @property def url(self) -> str: @@ -83,7 +82,7 @@ def is_interactive(self, xpath: str) -> bool: def element(self, xpath: str, wait: bool = True) -> GenericElement: if not wait: return self.factory.get(xpath) - return self.factory.get_when_available(xpath, self.timeout) + return self.factory.get_when_available(xpath) def elements(self, xpath: str) -> list[GenericElement]: return self.factory.get_many(xpath) @@ -126,7 +125,7 @@ def __init__( webdriver: WebDriver | None = None, options_class: BrowserOptionsClass = ChromeOptions, browser_arguments: list[str] | None = None, - timeout: int = TIMEOUT, + timeout: int = 15, ): self._browser_options: BrowserOptions | None = None self._webdriver = webdriver diff --git a/fastrpa/core/cookies.py b/fastrpa/core/cookies.py index f1c2d69..b081a52 100644 --- a/fastrpa/core/cookies.py +++ b/fastrpa/core/cookies.py @@ -1,9 +1,8 @@ from typing import Any -from fastrpa.commons import get_domain +from fastrpa.utils import get_domain from fastrpa.exceptions import CookieNotAdded -from fastrpa.types import WebDriver -from fastrpa.dataclasses import Cookie +from fastrpa.types import WebDriver, Cookie class Cookies: diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index 9ecc693..1aa2c97 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -5,9 +5,8 @@ from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.common.by import By -from fastrpa.commons import get_file_path, print_table -from fastrpa.dataclasses import Item, Option -from fastrpa.types import WebDriver +from fastrpa.utils import get_file_path, print_table +from fastrpa.types import WebDriver, Item, Option class Element: diff --git a/fastrpa/factory.py b/fastrpa/core/elements_factory.py similarity index 86% rename from fastrpa/factory.py rename to fastrpa/core/elements_factory.py index 636676a..d6b950c 100644 --- a/fastrpa/factory.py +++ b/fastrpa/core/elements_factory.py @@ -15,14 +15,14 @@ SelectElement, TableElement, ) -from fastrpa.decorators import ensure_element +from fastrpa.utils import ensure_element from fastrpa.exceptions import ElementNotFound, ElementNotFoundAfterTime -from fastrpa.settings import TIMEOUT from fastrpa.types import WebDriver -class ElementFactory: - def __init__(self, webdriver: WebDriver): +class ElementsFactory: + def __init__(self, webdriver: WebDriver, timeout: int): + self.timeout = timeout self.webdriver = webdriver def element_class(self, element: WebElement) -> Type[Element]: @@ -55,9 +55,11 @@ def element_class(self, element: WebElement) -> Type[Element]: return Element @ensure_element - def get_when_available(self, xpath: str, timeout: int = TIMEOUT) -> Element: + def get_when_available(self, xpath: str) -> Element: try: - selenium_element = WebDriverWait(self.webdriver, timeout).until( + selenium_element = WebDriverWait( + self.webdriver, self.timeout + ).until( expected_conditions.presence_of_element_located( (By.XPATH, xpath) ) @@ -66,7 +68,7 @@ def get_when_available(self, xpath: str, timeout: int = TIMEOUT) -> Element: return element_class(xpath, self.webdriver) except TimeoutException: - raise ElementNotFoundAfterTime(xpath, timeout) + raise ElementNotFoundAfterTime(xpath, self.timeout) @ensure_element def get(self, xpath: str) -> Element: diff --git a/fastrpa/core/wait.py b/fastrpa/core/wait.py index d54c58d..664eabe 100644 --- a/fastrpa/core/wait.py +++ b/fastrpa/core/wait.py @@ -12,75 +12,75 @@ def __init__(self, webdriver: WebDriver, timeout: float): self._timeout = timeout self.webdriver = webdriver - def get_timeout(self, value: float | None) -> float: + def _get_timeout(self, value: float | None) -> float: if value is not None: return value return self._timeout def source(self, timeout: float | None = None) -> WebDriverWait: - return WebDriverWait(self.webdriver, self.get_timeout(timeout)) + return WebDriverWait(self.webdriver, self._get_timeout(timeout)) def seconds(self, seconds: float): sleep(seconds) def is_present(self, xpath: str, timeout: float | None = None): - self.source(self.get_timeout(timeout)).until( + self.source(self._get_timeout(timeout)).until( EC.presence_of_element_located((By.XPATH, xpath)) ) def not_is_present(self, xpath: str, timeout: float | None = None): - self.source(self.get_timeout(timeout)).until_not( + self.source(self._get_timeout(timeout)).until_not( EC.presence_of_element_located((By.XPATH, xpath)) ) def is_clickable(self, xpath: str, timeout: float | None = None): - self.source(self.get_timeout(timeout)).until( + self.source(self._get_timeout(timeout)).until( EC.element_to_be_clickable((By.XPATH, xpath)) ) def not_is_clickable(self, xpath: str, timeout: float | None = None): - self.source(self.get_timeout(timeout)).until_not( + self.source(self._get_timeout(timeout)).until_not( EC.element_to_be_clickable((By.XPATH, xpath)) ) def is_hidden(self, xpath: str, timeout: float | None = None): - self.source(self.get_timeout(timeout)).until( + self.source(self._get_timeout(timeout)).until( EC.invisibility_of_element_located((By.XPATH, xpath)) ) def not_is_hidden(self, xpath: str, timeout: float | None = None): - self.source(self.get_timeout(timeout)).until_not( + self.source(self._get_timeout(timeout)).until_not( EC.invisibility_of_element_located((By.XPATH, xpath)) ) def contains_text( self, xpath: str, text: str, timeout: float | None = None ): - self.source(self.get_timeout(timeout)).until( + self.source(self._get_timeout(timeout)).until( EC.text_to_be_present_in_element((By.XPATH, xpath), text) ) def not_contains_text( self, xpath: str, text: str, timeout: float | None = None ): - self.source(self.get_timeout(timeout)).until_not( + self.source(self._get_timeout(timeout)).until_not( EC.text_to_be_present_in_element((By.XPATH, xpath), text) ) def url_contains(self, sub_url: str, timeout: float | None = None): - self.source(self.get_timeout(timeout)).until(EC.url_contains(sub_url)) + self.source(self._get_timeout(timeout)).until(EC.url_contains(sub_url)) def not_url_contains(self, sub_url: str, timeout: float | None = None): - self.source(self.get_timeout(timeout)).until_not( + self.source(self._get_timeout(timeout)).until_not( EC.url_contains(sub_url) ) def title_contains(self, sub_title: str, timeout: float | None = None): - self.source(self.get_timeout(timeout)).until( + self.source(self._get_timeout(timeout)).until( EC.title_contains(sub_title) ) def not_title_contains(self, sub_title: str, timeout: float | None = None): - self.source(self.get_timeout(timeout)).until_not( + self.source(self._get_timeout(timeout)).until_not( EC.title_contains(sub_title) ) diff --git a/fastrpa/dataclasses.py b/fastrpa/dataclasses.py deleted file mode 100644 index dc1b48e..0000000 --- a/fastrpa/dataclasses.py +++ /dev/null @@ -1,48 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Literal - - -@dataclass -class Option: - value: str | None - label: str | None - - -@dataclass -class Item: - id: str | None - label: str | None - - -@dataclass -class Cookie: - name: str - value: str - domain: str - path: str = '/' - secure: bool = False - http_only: bool = True - same_site: Literal['Strict', 'Lax', 'None'] = 'None' - - @staticmethod - def from_selenium(content: dict[str, Any]) -> 'Cookie': - return Cookie( - name=content['name'], - value=content['value'], - domain=content['domain'], - path=content['path'], - secure=content['secure'], - http_only=content['httpOnly'], - same_site=content['sameSite'], - ) - - def to_selenium(self) -> dict[str, Any]: - return { - 'name': self.name, - 'value': self.value, - 'domain': self.domain, - 'path': self.path, - 'secure': self.secure, - 'httpOnly': self.http_only, - 'sameSite': self.same_site, - } diff --git a/fastrpa/decorators.py b/fastrpa/decorators.py deleted file mode 100644 index 601ed27..0000000 --- a/fastrpa/decorators.py +++ /dev/null @@ -1,14 +0,0 @@ -from typing import Callable -from selenium.common.exceptions import StaleElementReferenceException - - -def ensure_element(func: Callable, max_attempts: int = 3): - def wrapper(*args, **kwargs): - attempt = 0 - while attempt < max_attempts: - try: - return func(*args, **kwargs) - except StaleElementReferenceException: - attempt += 1 - - return wrapper diff --git a/fastrpa/exceptions.py b/fastrpa/exceptions.py index 1cd0302..bc3f6f0 100644 --- a/fastrpa/exceptions.py +++ b/fastrpa/exceptions.py @@ -8,7 +8,7 @@ def __init__(self, xpath: str): class ElementNotFoundAfterTime(Exception): message = 'Element [{}] not found after {} seconds!' - def __init__(self, xpath: str, timeout: int): + def __init__(self, xpath: str, timeout: float): super().__init__(self.message.format(xpath, timeout)) diff --git a/fastrpa/settings.py b/fastrpa/settings.py deleted file mode 100644 index 49ef147..0000000 --- a/fastrpa/settings.py +++ /dev/null @@ -1 +0,0 @@ -TIMEOUT = 15 diff --git a/fastrpa/types.py b/fastrpa/types.py index eb08df4..44514f0 100644 --- a/fastrpa/types.py +++ b/fastrpa/types.py @@ -1,4 +1,5 @@ -from typing import Type +from dataclasses import dataclass +from typing import Any, Literal, Type from selenium.webdriver import ( ChromeOptions, SafariOptions, @@ -17,8 +18,47 @@ ) -__all__ = ( - 'WebDriver', - 'BrowserOptions', - 'BrowserOptionsClass', -) +@dataclass +class Option: + value: str | None + label: str | None + + +@dataclass +class Item: + id: str | None + label: str | None + + +@dataclass +class Cookie: + name: str + value: str + domain: str + path: str = '/' + secure: bool = False + http_only: bool = True + same_site: Literal['Strict', 'Lax', 'None'] = 'None' + + @staticmethod + def from_selenium(content: dict[str, Any]) -> 'Cookie': + return Cookie( + name=content['name'], + value=content['value'], + domain=content['domain'], + path=content['path'], + secure=content['secure'], + http_only=content['httpOnly'], + same_site=content['sameSite'], + ) + + def to_selenium(self) -> dict[str, Any]: + return { + 'name': self.name, + 'value': self.value, + 'domain': self.domain, + 'path': self.path, + 'secure': self.secure, + 'httpOnly': self.http_only, + 'sameSite': self.same_site, + } diff --git a/fastrpa/commons.py b/fastrpa/utils.py similarity index 74% rename from fastrpa/commons.py rename to fastrpa/utils.py index 8469cae..7abaacf 100644 --- a/fastrpa/commons.py +++ b/fastrpa/utils.py @@ -1,6 +1,7 @@ -from typing import Iterable +from typing import Callable, Iterable from urllib.parse import urlparse from selenium.webdriver import ChromeOptions +from selenium.common.exceptions import StaleElementReferenceException from rich.table import Table from rich.console import Console @@ -46,3 +47,15 @@ def print_table(headers: Iterable[str], rows: Iterable[str]): for row in rows: rich_table.add_row(*row) Console().print(rich_table) + + +def ensure_element(func: Callable, max_attempts: int = 3): + def wrapper(*args, **kwargs): + attempt = 0 + while attempt < max_attempts: + try: + return func(*args, **kwargs) + except StaleElementReferenceException: + attempt += 1 + + return wrapper From c524ea43ef4c6cf86a9c1fd39a2df7bc00ee22b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Mon, 29 Jul 2024 17:28:57 -0300 Subject: [PATCH 40/78] feat - print lists and selects --- fastrpa/core/elements.py | 16 ++++++++++++---- fastrpa/utils.py | 8 ++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index 1aa2c97..2b25217 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -5,7 +5,7 @@ from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.common.by import By -from fastrpa.utils import get_file_path, print_table +from fastrpa.utils import get_file_path, print_list, print_table from fastrpa.types import WebDriver, Item, Option @@ -141,6 +141,10 @@ def has_option(self, label: str | None = None, value: str | None = None): def __contains__(self, key: Any) -> bool: return self.has_option(label=key) or self.has_option(value=key) + def print(self): + identifier = f'[@id="{self.id}"]' if self.id else self.xpath + print_list(f'{identifier}', self.options_values, self.options_labels) + class ListElement(Element): _items_sources: list[WebElement] | None = None @@ -197,6 +201,10 @@ def has_item(self, label: str | None = None, id: str | None = None): def __contains__(self, key: Any) -> bool: return self.has_item(label=key) or self.has_item(id=key) + def print(self): + identifier = f'[@id="{self.id}"]' if self.id else self.xpath + print_list(identifier, self.items_ids, self.items_labels) + class ButtonElement(Element): @property @@ -232,11 +240,11 @@ def type(self) -> str: return gotten_type return 'application/x-www-form-urlencoded' - def submit(self, button: ButtonElement | None = None): - if not button: + def submit(self, button_xpath: str | None = None): + if not button_xpath: self.source.submit() else: - button.click() + ButtonElement(button_xpath, self.webdriver).click() class TableElement(Element): diff --git a/fastrpa/utils.py b/fastrpa/utils.py index 7abaacf..754b155 100644 --- a/fastrpa/utils.py +++ b/fastrpa/utils.py @@ -2,6 +2,7 @@ from urllib.parse import urlparse from selenium.webdriver import ChromeOptions from selenium.common.exceptions import StaleElementReferenceException +from rich.tree import Tree from rich.table import Table from rich.console import Console @@ -49,6 +50,13 @@ def print_table(headers: Iterable[str], rows: Iterable[str]): Console().print(rich_table) +def print_list(name: str, ids: list[str], values: list[str]): + rich_tree = Tree(name.replace('[', '\[')) + for id, value in zip(ids, values): + rich_tree.add(f'[{id}] {value}') + Console().print(rich_tree) + + def ensure_element(func: Callable, max_attempts: int = 3): def wrapper(*args, **kwargs): attempt = 0 From 56862c4a7cbdabba38c7d5d755e7ab8d39314a25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Mon, 29 Jul 2024 18:01:41 -0300 Subject: [PATCH 41/78] feat - tests from xpath module --- fastrpa/xpath.py | 2 +- pyproject.toml | 9 +++ tests/test_xpath.py | 188 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 tests/test_xpath.py diff --git a/fastrpa/xpath.py b/fastrpa/xpath.py index c75f9fc..fcd5b70 100644 --- a/fastrpa/xpath.py +++ b/fastrpa/xpath.py @@ -7,7 +7,7 @@ def attribute_contains( def attribute_equals( element: str, attribute: str, value: str, child: str = '' ) -> str: - return f'//{element}[{attribute}, "{value}"]{child}' + return f'//{element}[{attribute}="{value}"]{child}' def id_contains(value: str, child: str = '') -> str: diff --git a/pyproject.toml b/pyproject.toml index a38c1ab..ae4b395 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,8 @@ rich = "^13.7.1" [tool.poetry.group.dev.dependencies] ruff = "^0.5.1" mypy = "^1.10.1" +pytest = "^8.3.2" +pytest-randomly = "^3.15.0" [build-system] requires = ["poetry-core"] @@ -26,3 +28,10 @@ line-length = 80 [tool.ruff.format] quote-style = "single" docstring-code-format = true + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "-vvv" +testpaths = [ + "tests", +] diff --git a/tests/test_xpath.py b/tests/test_xpath.py new file mode 100644 index 0000000..6564a20 --- /dev/null +++ b/tests/test_xpath.py @@ -0,0 +1,188 @@ +from fastrpa import xpath +from pytest import mark + + +@mark.parametrize( + ('element', 'attribute', 'value', 'child', 'expected_output'), + ( + ('div', '@id', 'someValue', '', '//div[contains(@id, "someValue")]'), + ('a', '@class', 'someValue', '/i', '//a[contains(@class, "someValue")]/i'), + ('*', 'data-value', 'someValue', '/i/div', '//*[contains(data-value, "someValue")]/i/div'), + ), + ids=( + '//div[contains(@id, "someValue")]', + '//a[contains(@class, "someValue")]/i', + '//*[contains(data-value, "someValue")]/i/div' + ) +) +def test_attribute_contains(element, attribute, value, child, expected_output): + assert xpath.attribute_contains(element, attribute, value, child) == expected_output + + +@mark.parametrize( + ('element', 'attribute', 'value', 'child', 'expected_output'), + ( + ('div', '@id', 'someValue', '', '//div[@id="someValue"]'), + ('a', '@class', 'someValue', '/i', '//a[@class="someValue"]/i'), + ('*', 'data-value', 'someValue', '/i/div', '//*[data-value="someValue"]/i/div'), + ), + ids=( + '//div[@id="someValue"]', + '//a[@class="someValue"]/i', + '//*[data-value="someValue"]/i/div' + ) +) +def test_attribute_equals(element, attribute, value, child, expected_output): + assert xpath.attribute_equals(element, attribute, value, child) == expected_output + + +@mark.parametrize( + ('value', 'child', 'expected_output'), + ( + ('someValue', '', '//*[contains(@id, "someValue")]'), + ('some.value', '/i/div', '//*[contains(@id, "some.value")]/i/div'), + ('some_value', '/i/div', '//*[contains(@id, "some_value")]/i/div'), + ('some-value', '/i/div', '//*[contains(@id, "some-value")]/i/div'), + ), + ids=( + '//*[contains(@id, "someValue")]', + '//*[contains(@id, "some.value")]/i/div', + '//*[contains(@id, "some_value")]/i/div', + '//*[contains(@id, "some-value")]/i/div' + ) +) +def test_id_contains(value, child, expected_output): + assert xpath.id_contains(value, child) == expected_output + + +@mark.parametrize( + ('value', 'child', 'expected_output'), + ( + ('someValue', '', '//*[@id="someValue"]'), + ('some.value', '/i/div', '//*[@id="some.value"]/i/div'), + ('some_value', '/i/div', '//*[@id="some_value"]/i/div'), + ('some-value', '/i/div', '//*[@id="some-value"]/i/div'), + ), + ids=( + '//*[@id="someValue"]', + '//*[@id="some.value"]/i/div', + '//*[@id="some_value"]/i/div', + '//*[@id="some-value"]/i/div' + ) +) +def test_id_equals(value, child, expected_output): + assert xpath.id_equals(value, child) == expected_output + + +@mark.parametrize( + ('value', 'child', 'expected_output'), + ( + ('someValue', '', '//*[contains(@class, "someValue")]'), + ('some.value', '/i/div', '//*[contains(@class, "some.value")]/i/div'), + ('some_value', '/i/div', '//*[contains(@class, "some_value")]/i/div'), + ('some-value', '/i/div', '//*[contains(@class, "some-value")]/i/div'), + ), + ids=( + '//*[contains(@class, "someValue")]', + '//*[contains(@class, "some.value")]/i/div', + '//*[contains(@class, "some_value")]/i/div', + '//*[contains(@class, "some-value")]/i/div' + ) +) +def test_class_contains(value, child, expected_output): + assert xpath.class_contains(value, child) == expected_output + + +@mark.parametrize( + ('value', 'child', 'expected_output'), + ( + ('someValue', '', '//*[@class="someValue"]'), + ('some.value', '/i/div', '//*[@class="some.value"]/i/div'), + ('some_value', '/i/div', '//*[@class="some_value"]/i/div'), + ('some-value', '/i/div', '//*[@class="some-value"]/i/div'), + ), + ids=( + '//*[@class="someValue"]', + '//*[@class="some.value"]/i/div', + '//*[@class="some_value"]/i/div', + '//*[@class="some-value"]/i/div' + ) +) +def test_class_equals(value, child, expected_output): + assert xpath.class_equals(value, child) == expected_output + + +@mark.parametrize( + ('value', 'child', 'expected_output'), + ( + ('someValue', '', '//*[contains(@name, "someValue")]'), + ('some.value', '/i/div', '//*[contains(@name, "some.value")]/i/div'), + ('some_value', '/i/div', '//*[contains(@name, "some_value")]/i/div'), + ('some-value', '/i/div', '//*[contains(@name, "some-value")]/i/div'), + ), + ids=( + '//*[contains(@name, "someValue")]', + '//*[contains(@name, "some.value")]/i/div', + '//*[contains(@name, "some_value")]/i/div', + '//*[contains(@name, "some-value")]/i/div' + ) +) +def test_name_contains(value, child, expected_output): + assert xpath.name_contains(value, child) == expected_output + + +@mark.parametrize( + ('value', 'child', 'expected_output'), + ( + ('someValue', '', '//*[@name="someValue"]'), + ('some.value', '/i/div', '//*[@name="some.value"]/i/div'), + ('some_value', '/i/div', '//*[@name="some_value"]/i/div'), + ('some-value', '/i/div', '//*[@name="some-value"]/i/div'), + ), + ids=( + '//*[@name="someValue"]', + '//*[@name="some.value"]/i/div', + '//*[@name="some_value"]/i/div', + '//*[@name="some-value"]/i/div' + ) +) +def test_name_equals(value, child, expected_output): + assert xpath.name_equals(value, child) == expected_output + + +@mark.parametrize( + ('value', 'child', 'expected_output'), + ( + ('someValue', '', '//*[contains(text(), "someValue")]'), + ('some.value', '/i/div', '//*[contains(text(), "some.value")]/i/div'), + ('some_value', '/i/div', '//*[contains(text(), "some_value")]/i/div'), + ('some-value', '/i/div', '//*[contains(text(), "some-value")]/i/div'), + ), + ids=( + '//*[contains(text(), "someValue")]', + '//*[contains(text(), "some.value")]/i/div', + '//*[contains(text(), "some_value")]/i/div', + '//*[contains(text(), "some-value")]/i/div' + ) +) +def test_text_contains(value, child, expected_output): + assert xpath.text_contains(value, child) == expected_output + + +@mark.parametrize( + ('value', 'child', 'expected_output'), + ( + ('someValue', '', '//*[text()="someValue"]'), + ('some.value', '/i/div', '//*[text()="some.value"]/i/div'), + ('some_value', '/i/div', '//*[text()="some_value"]/i/div'), + ('some-value', '/i/div', '//*[text()="some-value"]/i/div'), + ), + ids=( + '//*[text()="someValue"]', + '//*[text()="some.value"]/i/div', + '//*[text()="some_value"]/i/div', + '//*[text()="some-value"]/i/div' + ) +) +def test_text_equals(value, child, expected_output): + assert xpath.text_equals(value, child) == expected_output From 88e927957e8522005f03ddc03a6901c8e930f151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Mon, 29 Jul 2024 20:33:43 -0300 Subject: [PATCH 42/78] feat - file abstraction --- fastrpa/app.py | 23 +++++- fastrpa/core/cookies.py | 4 +- fastrpa/core/elements.py | 131 +++++++++++++++++++++---------- fastrpa/core/elements_factory.py | 12 ++- fastrpa/core/file.py | 46 +++++++++++ fastrpa/core/keyboard.py | 4 +- fastrpa/core/screenshot.py | 10 ++- fastrpa/exceptions.py | 41 ++++++++-- fastrpa/types.py | 21 +++-- fastrpa/utils.py | 76 +++++++++++------- pyproject.toml | 2 +- 11 files changed, 265 insertions(+), 105 deletions(-) create mode 100644 fastrpa/core/file.py diff --git a/fastrpa/app.py b/fastrpa/app.py index 9086900..3f4e6e3 100644 --- a/fastrpa/app.py +++ b/fastrpa/app.py @@ -1,4 +1,5 @@ from typing import Type, TypeVar, Union +from requests import Session from selenium.webdriver import Remote, ChromeOptions from selenium.webdriver.common.by import By from selenium.common.exceptions import ( @@ -6,16 +7,19 @@ StaleElementReferenceException, ) from fastrpa.utils import ( + find_element, get_browser_options, get_domain, + get_session, ) from fastrpa.core.console import Console from fastrpa.core.cookies import Cookies from fastrpa.core.screenshot import Screenshot from fastrpa.core.tabs import Tabs -from fastrpa.exceptions import ElementNotCompatible +from fastrpa.exceptions import ElementNotCompatibleException from fastrpa.core.elements import ( Element, + ImageElement, InputElement, FileInputElement, ButtonElement, @@ -63,6 +67,10 @@ def domain(self) -> str: def title(self) -> str: return self.webdriver.title + @property + def session(self) -> Session: + return get_session(self.webdriver) + def browse(self, url: str): self.webdriver.get(url) @@ -79,6 +87,14 @@ def is_interactive(self, xpath: str) -> bool: except StaleElementReferenceException: return False + def read(self, xpath: str) -> str | None: + try: + if element := find_element(self.webdriver, xpath): + return element.text if element.text else None + return None + except NoSuchElementException: + return None + def element(self, xpath: str, wait: bool = True) -> GenericElement: if not wait: return self.factory.get(xpath) @@ -92,7 +108,7 @@ def _specific_element( ) -> SpecificElement: element = self.element(xpath, wait) if not isinstance(element, class_name): - raise ElementNotCompatible(xpath, class_name) + raise ElementNotCompatibleException(xpath, class_name) return element def input(self, xpath: str, wait: bool = True) -> InputElement: @@ -116,6 +132,9 @@ def list(self, xpath: str, wait: bool = True) -> ListElement: def table(self, xpath: str, wait: bool = True) -> TableElement: return self._specific_element(xpath, TableElement, wait) + def image(self, xpath: str, wait: bool = True) -> ImageElement: + return self._specific_element(xpath, ImageElement, wait) + class FastRPA: browser_arguments = ['--start-maximized', '--ignore-certificate-errors'] diff --git a/fastrpa/core/cookies.py b/fastrpa/core/cookies.py index b081a52..214252a 100644 --- a/fastrpa/core/cookies.py +++ b/fastrpa/core/cookies.py @@ -1,7 +1,7 @@ from typing import Any from fastrpa.utils import get_domain -from fastrpa.exceptions import CookieNotAdded +from fastrpa.exceptions import CookieNotAddedException from fastrpa.types import WebDriver, Cookie @@ -34,7 +34,7 @@ def add(self, name: str, value: str, secure: bool = False) -> Cookie: self.webdriver.add_cookie(cookie.to_selenium()) if added_cookie := self.webdriver.get_cookie(name): return Cookie.from_selenium(added_cookie) - raise CookieNotAdded(name) + raise CookieNotAddedException(name) def delete(self, name: str): self.webdriver.delete_cookie(name) diff --git a/fastrpa/core/elements.py b/fastrpa/core/elements.py index 2b25217..bbdbf99 100644 --- a/fastrpa/core/elements.py +++ b/fastrpa/core/elements.py @@ -1,23 +1,31 @@ from time import sleep from typing import Any +from selenium.common.exceptions import NoSuchElementException from selenium.webdriver import ActionChains from selenium.webdriver.support.ui import Select from selenium.webdriver.remote.webelement import WebElement -from selenium.webdriver.common.by import By -from fastrpa.utils import get_file_path, print_list, print_table -from fastrpa.types import WebDriver, Item, Option +from fastrpa.core.file import File +from fastrpa.exceptions import FileNotDownloadableException, FormException +from fastrpa.utils import ( + find_element, + find_elements, + print_list, + print_table, +) +from fastrpa.types import WebDriver class Element: def __init__(self, xpath: str, webdriver: WebDriver) -> None: self.xpath = xpath self.webdriver = webdriver + self.file = File(self.webdriver) self.actions = ActionChains(self.webdriver) @property def source(self) -> WebElement: - return self.webdriver.find_element(By.XPATH, self.xpath) + return find_element(self.webdriver, self.xpath) def attribute(self, name: str) -> str | None: return self.source.get_attribute(name) @@ -87,17 +95,13 @@ def fill_slowly(self, value: str, delay: float = 0.3): class FileInputElement(Element): def attach_file(self, path: str): - self.source.send_keys(get_file_path(path)) + self.source.send_keys(self.file.get_path(path)) class SelectElement(Element): - _select_source: Select | None = None - @property def select_source(self) -> Select: - if not self._select_source: - self._select_source = Select(self.source) - return self._select_source + return Select(self.source) @property def options_values(self) -> list[str | None]: @@ -114,14 +118,17 @@ def options_labels(self) -> list[str | None]: ] @property - def options(self) -> list[Option]: - return [ - Option( - option.get_attribute('value'), - option.get_attribute('innerText'), - ) + def options(self) -> dict[str | None, str | None]: + return { + option.get_attribute('value'): option.get_attribute('innerText') for option in self.select_source.options - ] + } + + @property + def current(self) -> tuple[str | None, str | None]: + if value_id := self.attribute('value'): + return value_id, self.options[value_id] + return None, None def select(self, label: str | None = None, value: str | None = None): if label: @@ -143,28 +150,24 @@ def __contains__(self, key: Any) -> bool: def print(self): identifier = f'[@id="{self.id}"]' if self.id else self.xpath - print_list(f'{identifier}', self.options_values, self.options_labels) + print_list(f'{identifier}', self.options) class ListElement(Element): - _items_sources: list[WebElement] | None = None - @property def items_sources(self) -> list[WebElement]: - if self._items_sources is None: - self._items_sources = self.source.find_elements(By.XPATH, './/li') - return self._items_sources + return find_elements(self.webdriver, './/li') @property def is_ordered(self) -> bool: return self.tag == 'ol' @property - def items(self) -> list[Item]: - return [ - Item(item.get_attribute('id'), item.get_attribute('innerText')) + def items(self) -> dict[str | None, str | None]: + return { + item.get_attribute('id'): item.get_attribute('innerText') for item in self.items_sources - ] + } @property def items_ids(self) -> list[str | None]: @@ -203,7 +206,7 @@ def __contains__(self, key: Any) -> bool: def print(self): identifier = f'[@id="{self.id}"]' if self.id else self.xpath - print_list(identifier, self.items_ids, self.items_labels) + print_list(identifier, self.items) class ButtonElement(Element): @@ -224,6 +227,25 @@ def double_click(self): class FormElement(Element): + _success_redirect_url: str | None = None + _success_elements_to_find: list[str] = [] + _success_text_to_find: list[str] = [] + + def _check_success_conditions(self): + if self._success_redirect_url is not None: + if self.webdriver.current_url != self._success_redirect_url: + raise FormException('redirect_url', self._success_redirect_url) + + for element in self._success_elements_to_find: + try: + find_element(self.webdriver, element) + except NoSuchElementException: + raise FormException('elements_to_find', element) + + for text in self._success_text_to_find: + if text not in self.webdriver.page_source: + raise FormException('text_to_find', text) + @property def method(self) -> str: if gotten_method := self.attribute('method'): @@ -240,23 +262,30 @@ def type(self) -> str: return gotten_type return 'application/x-www-form-urlencoded' + def set_success_condition( + self, + redirect_url: str | None = None, + elements_to_find: list[str] = [], + text_to_find: list[str] = [], + ): + self._success_redirect_url = redirect_url + self._success_elements_to_find = elements_to_find + self._success_text_to_find = text_to_find + def submit(self, button_xpath: str | None = None): if not button_xpath: self.source.submit() else: - ButtonElement(button_xpath, self.webdriver).click() + button = ButtonElement(button_xpath, self.webdriver) + button.click() + self._check_success_conditions() class TableElement(Element): - _headers_sources: list[WebElement] | None = None - _rows_sources: list[WebElement] | None = None - @property def headers_sources(self) -> list[WebElement]: - if self._headers_sources is None: - first_row = self.source.find_element(By.XPATH, './/tr') - self._headers_sources = first_row.find_elements(By.XPATH, './/th') - return self._headers_sources + first_row = find_element(self.source, './/tr') + return find_elements(first_row, './/th') @property def headers(self) -> list[str | None]: @@ -267,12 +296,10 @@ def headers(self) -> list[str | None]: @property def rows_sources(self) -> list[WebElement]: - if self._rows_sources is None: - rows = self.source.find_elements(By.XPATH, './/tr') - if self.headers: - del rows[0] - self._rows_sources = rows - return self._rows_sources + rows = find_elements(self.source, './/tr') + if self.headers: + del rows[0] + return rows @property def rows(self) -> list[list[str | None]]: @@ -281,7 +308,7 @@ def rows(self) -> list[list[str | None]]: rows_content.append( [ cell.get_attribute('innerText') - for cell in element.find_elements(By.XPATH, './/td | .//th') + for cell in find_elements(element, './/td | .//th') ] ) return rows_content @@ -309,3 +336,21 @@ def __contains__(self, value: Any) -> bool: def print(self): print_table(self.headers, self.rows) + + +class ImageElement(Element): + @property + def text(self) -> str | None: + return self.attribute('alt') + + @property + def reference(self) -> str | None: + return self.attribute('src') + + def save(self, path: str | None = None): + if not self.reference: + raise FileNotDownloadableException(self.reference, self.xpath) + if path is not None: + self.file.download_file(self.reference, path) + else: + self.file.download_file_to_cwd(self.reference) diff --git a/fastrpa/core/elements_factory.py b/fastrpa/core/elements_factory.py index d6b950c..19af03f 100644 --- a/fastrpa/core/elements_factory.py +++ b/fastrpa/core/elements_factory.py @@ -10,13 +10,14 @@ Element, FileInputElement, FormElement, + ImageElement, InputElement, ListElement, SelectElement, TableElement, ) from fastrpa.utils import ensure_element -from fastrpa.exceptions import ElementNotFound, ElementNotFoundAfterTime +from fastrpa.exceptions import ElementNotFoundException, ElementTimeoutException from fastrpa.types import WebDriver @@ -52,6 +53,9 @@ def element_class(self, element: WebElement) -> Type[Element]: elif element.tag_name == 'table': return TableElement + elif element.tag_name == 'img': + return ImageElement + return Element @ensure_element @@ -68,7 +72,7 @@ def get_when_available(self, xpath: str) -> Element: return element_class(xpath, self.webdriver) except TimeoutException: - raise ElementNotFoundAfterTime(xpath, self.timeout) + raise ElementTimeoutException(xpath, self.timeout) @ensure_element def get(self, xpath: str) -> Element: @@ -78,7 +82,7 @@ def get(self, xpath: str) -> Element: return element_class(xpath, self.webdriver) except NoSuchElementException: - raise ElementNotFound(xpath) + raise ElementNotFoundException(xpath) @ensure_element def get_many(self, xpath: str) -> list[Element]: @@ -94,4 +98,4 @@ def get_many(self, xpath: str) -> list[Element]: return elements_to_return except NoSuchElementException: - raise ElementNotFound(xpath) + raise ElementNotFoundException(xpath) diff --git a/fastrpa/core/file.py b/fastrpa/core/file.py new file mode 100644 index 0000000..5c87beb --- /dev/null +++ b/fastrpa/core/file.py @@ -0,0 +1,46 @@ +import os + +from mimetypes import guess_extension +from requests import Response + +from fastrpa.types import WebDriver +from fastrpa.utils import get_session + + +class File: + def __init__(self, webdriver: WebDriver) -> None: + self.webdriver = webdriver + + def get_extension(self, response: Response) -> str: + content_type = response.headers['Content-Type'] + mime_type, *_ = content_type.split(';') + if extension := guess_extension(mime_type): + return extension + raise ValueError('The response does not have a Content-Type header!') + + def get_hash(self, response: Response) -> str: + return str(abs(hash(response.content))) + + def download_file(self, url: str, path: str | None = None) -> str: + response = get_session(self.webdriver).get(url) + extension = self.get_extension(response) + f_hash = self.get_hash(response) + + if path is None: + path = '/tmp/' + f_hash + extension + + elif '.' not in path: + path = os.path.join(path, f_hash + extension) + + with open(path, 'wb') as file: + file.write(response.content) + + return path + + def download_file_to_cwd(self, url: str) -> str: + return self.download_file(url, os.getcwd()) + + def get_path(self, path: str) -> str: + if os.path.isfile(path): + return path + return self.download_file(path) diff --git a/fastrpa/core/keyboard.py b/fastrpa/core/keyboard.py index 8aee0e9..f5df32a 100644 --- a/fastrpa/core/keyboard.py +++ b/fastrpa/core/keyboard.py @@ -2,7 +2,7 @@ from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.action_chains import ActionChains -from fastrpa.exceptions import KeyDoesNotExists +from fastrpa.exceptions import KeyDoesNotExistsException from fastrpa.types import WebDriver @@ -47,7 +47,7 @@ def key_code(self, key: str) -> str: return key elif len(key) == 1: return key - raise KeyDoesNotExists(key) + raise KeyDoesNotExistsException(key) def press(self, key: str): self.actions.send_keys(self.key_code(key)) diff --git a/fastrpa/core/screenshot.py b/fastrpa/core/screenshot.py index d838d5f..e206c41 100644 --- a/fastrpa/core/screenshot.py +++ b/fastrpa/core/screenshot.py @@ -1,6 +1,6 @@ from datetime import datetime from selenium.webdriver.common.by import By -from fastrpa.exceptions import ScreenshotNotTaken +from fastrpa.exceptions import ScreenshotNotTakenException from fastrpa.types import WebDriver @@ -72,7 +72,9 @@ def full_page_image(self) -> bytes: def save_image(self, path: str | None = None): success = self.webdriver.save_screenshot(self._file_path(path)) if not success: - raise ScreenshotNotTaken('image', self.webdriver.current_url) + raise ScreenshotNotTakenException( + 'image', self.webdriver.current_url + ) def save_full_page_image(self, path: str | None = None): starter_size = self.webdriver.get_window_size() @@ -85,7 +87,9 @@ def save_full_page_image(self, path: str | None = None): success = element.screenshot(self._file_path(path)) if not success: - raise ScreenshotNotTaken('image', self.webdriver.current_url) + raise ScreenshotNotTakenException( + 'image', self.webdriver.current_url + ) finally: self._restore_window( diff --git a/fastrpa/exceptions.py b/fastrpa/exceptions.py index bc3f6f0..1124baf 100644 --- a/fastrpa/exceptions.py +++ b/fastrpa/exceptions.py @@ -1,40 +1,69 @@ -class ElementNotFound(Exception): +class ElementNotFoundException(Exception): message = 'No one element [{}] was found!' def __init__(self, xpath: str): + self.xpath = xpath super().__init__(self.message.format(xpath)) -class ElementNotFoundAfterTime(Exception): +class ElementTimeoutException(Exception): message = 'Element [{}] not found after {} seconds!' def __init__(self, xpath: str, timeout: float): + self.xpath = xpath + self.timeout = timeout super().__init__(self.message.format(xpath, timeout)) -class ElementNotCompatible(Exception): +class ElementNotCompatibleException(Exception): message = 'Element [{}] is not compatible with {}!' def __init__(self, xpath: str, class_name: type): + self.xpath = xpath + self.class_name = class_name super().__init__(self.message.format(xpath, class_name)) -class ScreenshotNotTaken(Exception): +class ScreenshotNotTakenException(Exception): message = 'The [{}] screenshot from [{}] was not taken!' def __init__(self, type: str, url: str): + self.type = type + self.url = url super().__init__(self.message.format(type, url)) -class CookieNotAdded(Exception): +class CookieNotAddedException(Exception): message = 'The cookie [{}] was not added!' def __init__(self, cookie_name: str): + self.cookie_name = cookie_name super().__init__(self.message.format(cookie_name)) -class KeyDoesNotExists(Exception): +class KeyDoesNotExistsException(Exception): message = 'The key [{}] does not exists!' def __init__(self, key: str): + self.key = key super().__init__(self.message.format(key)) + + +class FormException(Exception): + message = ( + 'The form submission got an error! Condition [{}, {}] not satisfied!' + ) + + def __init__(self, condition: str, expected_value: str): + self.condition = condition + self.expected_value = expected_value + super().__init__(self.message.format(condition, expected_value)) + + +class FileNotDownloadableException(Exception): + message = 'The file [{}] at [{}] is not downloadable!' + + def __init__(self, url: str | None, xpath: str): + self.url = url + self.xpath = xpath + super().__init__(self.message.format(url, xpath)) diff --git a/fastrpa/types.py b/fastrpa/types.py index 44514f0..75569d8 100644 --- a/fastrpa/types.py +++ b/fastrpa/types.py @@ -18,18 +18,6 @@ ) -@dataclass -class Option: - value: str | None - label: str | None - - -@dataclass -class Item: - id: str | None - label: str | None - - @dataclass class Cookie: name: str @@ -62,3 +50,12 @@ def to_selenium(self) -> dict[str, Any]: 'httpOnly': self.http_only, 'sameSite': self.same_site, } + + def to_requests(self) -> dict[str, Any]: + return { + 'name': self.name, + 'value': self.value, + 'domain': self.domain, + 'path': self.path, + 'secure': self.secure, + } diff --git a/fastrpa/utils.py b/fastrpa/utils.py index 754b155..f3c2a06 100644 --- a/fastrpa/utils.py +++ b/fastrpa/utils.py @@ -2,13 +2,9 @@ from urllib.parse import urlparse from selenium.webdriver import ChromeOptions from selenium.common.exceptions import StaleElementReferenceException -from rich.tree import Tree -from rich.table import Table -from rich.console import Console - -import os -import requests -import mimetypes +from selenium.webdriver.remote.webelement import WebElement +from selenium.webdriver.common.by import By +from requests import Session from fastrpa.types import BrowserOptions, BrowserOptionsClass, WebDriver @@ -22,39 +18,59 @@ def get_browser_options( return instance -def get_file_path(path: str) -> str: - if os.path.isfile(path): - return path +def get_session(webdriver: WebDriver) -> Session: + obj = Session() + for cookie in webdriver.get_cookies(): + obj.cookies.set( + name=cookie['name'], + value=cookie['value'], + domain=cookie['domain'], + path=cookie['path'], + secure=cookie['secure'], + ) + return obj - file_response = requests.get(path) - file_extension = mimetypes.guess_extension( - file_response.headers['Content-Type'] - ) - file_hash = abs(hash(file_response.content)) - download_path = f'/tmp/{file_hash}{file_extension}' - with open(download_path, 'wb') as file: - file.write(file_response.content) +def get_domain(webdriver: WebDriver) -> str: + return urlparse(webdriver.current_url).netloc - return download_path +def find_element(browsable: WebDriver | WebElement, xpath: str) -> WebElement: + return browsable.find_element(By.XPATH, xpath) -def get_domain(webdriver: WebDriver) -> str: - return urlparse(webdriver.current_url).netloc + +def find_elements( + browsable: WebDriver | WebElement, xpath: str +) -> list[WebElement]: + return browsable.find_elements(By.XPATH, xpath) def print_table(headers: Iterable[str], rows: Iterable[str]): - rich_table = Table(*headers) - for row in rows: - rich_table.add_row(*row) - Console().print(rich_table) + try: + from rich.table import Table + from rich.console import Console + + rich_table = Table(*headers) + for row in rows: + rich_table.add_row(*row) + Console().print(rich_table) + + except ImportError: + raise EnvironmentError('You need to install rich to print tables.') + + +def print_list(name: str, values_dict: dict[str | None, str | None]): + try: + from rich.tree import Tree + from rich.console import Console + rich_tree = Tree(name.replace('[', '\[')) + for id, value in values_dict.items(): + rich_tree.add(f'[{id}] {value}') + Console().print(rich_tree) -def print_list(name: str, ids: list[str], values: list[str]): - rich_tree = Tree(name.replace('[', '\[')) - for id, value in zip(ids, values): - rich_tree.add(f'[{id}] {value}') - Console().print(rich_tree) + except ImportError: + raise EnvironmentError('You need to install rich to print lists.') def ensure_element(func: Callable, max_attempts: int = 3): diff --git a/pyproject.toml b/pyproject.toml index ae4b395..c997f68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,13 +10,13 @@ python = "^3.11" selenium = "^4.22.0" requests = "^2.32.3" types-requests = "^2.32.0.20240622" -rich = "^13.7.1" [tool.poetry.group.dev.dependencies] ruff = "^0.5.1" mypy = "^1.10.1" pytest = "^8.3.2" pytest-randomly = "^3.15.0" +rich = "^13.7.1" [build-system] requires = ["poetry-core"] From 90cadac34eb870d25fe01031d4175d99e57a8b53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Mon, 29 Jul 2024 21:16:43 -0300 Subject: [PATCH 43/78] feat - updating readme --- README.md | 162 +++++++++++++++++++++++++++++++++++++++++++------ fastrpa/app.py | 7 ++- 2 files changed, 148 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index a21538e..d349e78 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ A simple to use abstraction over Selenium. - [Lists](#lists) - [Buttons and links](#buttons-and-links) - [Tables](#tables) - - [Medias](#medias) + - [Images](#images) ## Configure Selenium integration @@ -67,10 +67,39 @@ fastrpa.app.Web Once you have a `Web` object, you are able to browse on the web. The `Web` class is a abstraction of main browser and user functions. -It includes, managing: +```python +# The current URL from the browser +>>> web.url +'https://www.site.com/mypage' + +# The domain from the current URL +>>> web.domain +'www.site.com' + +# The title from the current page +>>> web.title +'My website' + +# Navigate to an URL +>>> web.browse('https://www.site.com/another_page') + +# Refresh the current page +>>> web.refresh() + +# Check if an element is interactive on the screen +>>> web.is_interactive('//*[@id="myElement"]') +False + +# Get the from text content from an element +>>> web.read('//*[@id="myElement"]') +'Any text' + +``` + +You can also, manage the following items: - [`keyboard`](#pressing-keys), to send key pressing events on the current page -- [`timer`](#waiting-for-events), to wait for some events on the current page +- [`wait`](#waiting-for-events), to wait for some events on the current page - [`cookies`](#managing-cookies), to manage cookies on the current page - [`screenshot`](#take-screenshots-and-prints), to download screenshots and prints from the current page - [`tabs`](#manage-and-navigate-through-opened-tabs), to manage and navigate through the current opened tabs @@ -82,8 +111,8 @@ You can access these abstractions by calling it from the `Web` object. >>> web.keyboard ->>> web.timer - +>>> web.wait + >>> web.cookies @@ -306,17 +335,43 @@ To start our interactions with page elements, we just need to obtain these with ] ``` +By default, FastRPA always waits until the element is interactable. The default timeout is 15 seconds, and it is configurable by the FastRPA constructor. In case of timeout, you will receive a `ElementTimeoutException`. + +```python +>>> app = FastRPA(timeout=60) +>>> web = app.browse('https:...') + +# If after the timeout, the element isn't avaliable +>>> web.elements('//*[@id="my_div"]') +ElementTimeoutException: Element [//*[@id="my_div"]] not found after 60 seconds! +``` + +If you don't want to wait, just send a wait=False parameter to the element method. + +```python +>>> app = FastRPA(timeout=60) +>>> web = app.browse('https:...') + +# Get an element without waiting +>>> web.elements('//*[@id="my_div"]', wait=False) + + +# Try to get a element that is not on the page +>>> web.elements('//*[@id="my_div"]', wait=False) +ElementNotFoundException: No one element [//*[@id="my_div"]] was found! +``` + There is some abstractions that implements actions and rules for specific elements. They is listed below. - `Element`, for any element -- [`InputElement`](#inputs), for fillable inputs (``) -- [`FileInputElement`](#file-inputs), for file inputs (``) -- [`SelectElement`](#selects), for selects (` tag with attribute [@type="file"]. +--- + Interactions with `input` with attribute `type="file"`. ## Reading the element diff --git a/docs/elements/forms.md b/docs/elements/forms.md index 04aaf00..d5459d5 100644 --- a/docs/elements/forms.md +++ b/docs/elements/forms.md @@ -1,3 +1,7 @@ +--- +description: Interactions with
tag. +--- + Interactions with `form` tag. ## Reading the element diff --git a/docs/elements/images.md b/docs/elements/images.md index 18a0cd9..bcc3ec5 100644 --- a/docs/elements/images.md +++ b/docs/elements/images.md @@ -1,3 +1,7 @@ +--- +description: Interactions with tag. +--- + Interactions with `img` tag. ## Reading the element diff --git a/docs/elements/index.md b/docs/elements/index.md index 063ea90..04d3ad3 100644 --- a/docs/elements/index.md +++ b/docs/elements/index.md @@ -1,3 +1,8 @@ +--- +title: Elements +description: Learn how to easily interact with page elements. +--- + # Elements !!! info "FastRPA is xpath-oriented!" diff --git a/docs/elements/inputs.md b/docs/elements/inputs.md index debb753..d9b257a 100644 --- a/docs/elements/inputs.md +++ b/docs/elements/inputs.md @@ -1,3 +1,7 @@ +--- +description: Interactions with and