381 lines
13 KiB
Python
Executable File
381 lines
13 KiB
Python
Executable File
# Licensed to the Software Freedom Conservancy (SFC) under one
|
|
# or more contributor license agreements. See the NOTICE file
|
|
# distributed with this work for additional information
|
|
# regarding copyright ownership. The SFC licenses this file
|
|
# to you under the Apache License, Version 2.0 (the
|
|
# "License"); you may not use this file except in compliance
|
|
# with the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing,
|
|
# software distributed under the License is distributed on an
|
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
# KIND, either express or implied. See the License for the
|
|
# specific language governing permissions and limitations
|
|
# under the License.
|
|
"""The ActionChains implementation."""
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
from typing import Union
|
|
|
|
from selenium.webdriver.remote.webelement import WebElement
|
|
|
|
from .actions.action_builder import ActionBuilder
|
|
from .actions.key_input import KeyInput
|
|
from .actions.pointer_input import PointerInput
|
|
from .actions.wheel_input import ScrollOrigin
|
|
from .actions.wheel_input import WheelInput
|
|
from .utils import keys_to_typing
|
|
|
|
if TYPE_CHECKING:
|
|
from selenium.webdriver.remote.webdriver import WebDriver
|
|
|
|
AnyDevice = Union[PointerInput, KeyInput, WheelInput]
|
|
|
|
|
|
class ActionChains:
|
|
"""ActionChains are a way to automate low level interactions such as mouse
|
|
movements, mouse button actions, key press, and context menu interactions.
|
|
This is useful for doing more complex actions like hover over and drag and
|
|
drop.
|
|
|
|
Generate user actions.
|
|
When you call methods for actions on the ActionChains object,
|
|
the actions are stored in a queue in the ActionChains object.
|
|
When you call perform(), the events are fired in the order they
|
|
are queued up.
|
|
|
|
ActionChains can be used in a chain pattern::
|
|
|
|
menu = driver.find_element(By.CSS_SELECTOR, ".nav")
|
|
hidden_submenu = driver.find_element(By.CSS_SELECTOR, ".nav #submenu1")
|
|
|
|
ActionChains(driver).move_to_element(menu).click(hidden_submenu).perform()
|
|
|
|
Or actions can be queued up one by one, then performed.::
|
|
|
|
menu = driver.find_element(By.CSS_SELECTOR, ".nav")
|
|
hidden_submenu = driver.find_element(By.CSS_SELECTOR, ".nav #submenu1")
|
|
|
|
actions = ActionChains(driver)
|
|
actions.move_to_element(menu)
|
|
actions.click(hidden_submenu)
|
|
actions.perform()
|
|
|
|
Either way, the actions are performed in the order they are called, one after
|
|
another.
|
|
"""
|
|
|
|
def __init__(self, driver: WebDriver, duration: int = 250, devices: list[AnyDevice] | None = None) -> None:
|
|
"""Creates a new ActionChains.
|
|
|
|
:Args:
|
|
- driver: The WebDriver instance which performs user actions.
|
|
- duration: override the default 250 msecs of DEFAULT_MOVE_DURATION in PointerInput
|
|
"""
|
|
self._driver = driver
|
|
mouse = None
|
|
keyboard = None
|
|
wheel = None
|
|
if devices is not None and isinstance(devices, list):
|
|
for device in devices:
|
|
if isinstance(device, PointerInput):
|
|
mouse = device
|
|
if isinstance(device, KeyInput):
|
|
keyboard = device
|
|
if isinstance(device, WheelInput):
|
|
wheel = device
|
|
self.w3c_actions = ActionBuilder(driver, mouse=mouse, keyboard=keyboard, wheel=wheel, duration=duration)
|
|
|
|
def perform(self) -> None:
|
|
"""Performs all stored actions."""
|
|
self.w3c_actions.perform()
|
|
|
|
def reset_actions(self) -> None:
|
|
"""Clears actions that are already stored locally and on the remote
|
|
end."""
|
|
self.w3c_actions.clear_actions()
|
|
for device in self.w3c_actions.devices:
|
|
device.clear_actions()
|
|
|
|
def click(self, on_element: WebElement | None = None) -> ActionChains:
|
|
"""Clicks an element.
|
|
|
|
:Args:
|
|
- on_element: The element to click.
|
|
If None, clicks on current mouse position.
|
|
"""
|
|
if on_element:
|
|
self.move_to_element(on_element)
|
|
|
|
self.w3c_actions.pointer_action.click()
|
|
self.w3c_actions.key_action.pause()
|
|
self.w3c_actions.key_action.pause()
|
|
|
|
return self
|
|
|
|
def click_and_hold(self, on_element: WebElement | None = None) -> ActionChains:
|
|
"""Holds down the left mouse button on an element.
|
|
|
|
:Args:
|
|
- on_element: The element to mouse down.
|
|
If None, clicks on current mouse position.
|
|
"""
|
|
if on_element:
|
|
self.move_to_element(on_element)
|
|
|
|
self.w3c_actions.pointer_action.click_and_hold()
|
|
self.w3c_actions.key_action.pause()
|
|
|
|
return self
|
|
|
|
def context_click(self, on_element: WebElement | None = None) -> ActionChains:
|
|
"""Performs a context-click (right click) on an element.
|
|
|
|
:Args:
|
|
- on_element: The element to context-click.
|
|
If None, clicks on current mouse position.
|
|
"""
|
|
if on_element:
|
|
self.move_to_element(on_element)
|
|
|
|
self.w3c_actions.pointer_action.context_click()
|
|
self.w3c_actions.key_action.pause()
|
|
self.w3c_actions.key_action.pause()
|
|
|
|
return self
|
|
|
|
def double_click(self, on_element: WebElement | None = None) -> ActionChains:
|
|
"""Double-clicks an element.
|
|
|
|
:Args:
|
|
- on_element: The element to double-click.
|
|
If None, clicks on current mouse position.
|
|
"""
|
|
if on_element:
|
|
self.move_to_element(on_element)
|
|
|
|
self.w3c_actions.pointer_action.double_click()
|
|
for _ in range(4):
|
|
self.w3c_actions.key_action.pause()
|
|
|
|
return self
|
|
|
|
def drag_and_drop(self, source: WebElement, target: WebElement) -> ActionChains:
|
|
"""Holds down the left mouse button on the source element, then moves
|
|
to the target element and releases the mouse button.
|
|
|
|
:Args:
|
|
- source: The element to mouse down.
|
|
- target: The element to mouse up.
|
|
"""
|
|
self.click_and_hold(source)
|
|
self.release(target)
|
|
return self
|
|
|
|
def drag_and_drop_by_offset(self, source: WebElement, xoffset: int, yoffset: int) -> ActionChains:
|
|
"""Holds down the left mouse button on the source element, then moves
|
|
to the target offset and releases the mouse button.
|
|
|
|
:Args:
|
|
- source: The element to mouse down.
|
|
- xoffset: X offset to move to.
|
|
- yoffset: Y offset to move to.
|
|
"""
|
|
self.click_and_hold(source)
|
|
self.move_by_offset(xoffset, yoffset)
|
|
self.release()
|
|
return self
|
|
|
|
def key_down(self, value: str, element: WebElement | None = None) -> ActionChains:
|
|
"""Sends a key press only, without releasing it. Should only be used
|
|
with modifier keys (Control, Alt and Shift).
|
|
|
|
:Args:
|
|
- value: The modifier key to send. Values are defined in `Keys` class.
|
|
- element: The element to send keys.
|
|
If None, sends a key to current focused element.
|
|
|
|
Example, pressing ctrl+c::
|
|
|
|
ActionChains(driver).key_down(Keys.CONTROL).send_keys('c').key_up(Keys.CONTROL).perform()
|
|
"""
|
|
if element:
|
|
self.click(element)
|
|
|
|
self.w3c_actions.key_action.key_down(value)
|
|
self.w3c_actions.pointer_action.pause()
|
|
|
|
return self
|
|
|
|
def key_up(self, value: str, element: WebElement | None = None) -> ActionChains:
|
|
"""Releases a modifier key.
|
|
|
|
:Args:
|
|
- value: The modifier key to send. Values are defined in Keys class.
|
|
- element: The element to send keys.
|
|
If None, sends a key to current focused element.
|
|
|
|
Example, pressing ctrl+c::
|
|
|
|
ActionChains(driver).key_down(Keys.CONTROL).send_keys('c').key_up(Keys.CONTROL).perform()
|
|
"""
|
|
if element:
|
|
self.click(element)
|
|
|
|
self.w3c_actions.key_action.key_up(value)
|
|
self.w3c_actions.pointer_action.pause()
|
|
|
|
return self
|
|
|
|
def move_by_offset(self, xoffset: int, yoffset: int) -> ActionChains:
|
|
"""Moving the mouse to an offset from current mouse position.
|
|
|
|
:Args:
|
|
- xoffset: X offset to move to, as a positive or negative integer.
|
|
- yoffset: Y offset to move to, as a positive or negative integer.
|
|
"""
|
|
|
|
self.w3c_actions.pointer_action.move_by(xoffset, yoffset)
|
|
self.w3c_actions.key_action.pause()
|
|
|
|
return self
|
|
|
|
def move_to_element(self, to_element: WebElement) -> ActionChains:
|
|
"""Moving the mouse to the middle of an element.
|
|
|
|
:Args:
|
|
- to_element: The WebElement to move to.
|
|
"""
|
|
|
|
self.w3c_actions.pointer_action.move_to(to_element)
|
|
self.w3c_actions.key_action.pause()
|
|
|
|
return self
|
|
|
|
def move_to_element_with_offset(self, to_element: WebElement, xoffset: int, yoffset: int) -> ActionChains:
|
|
"""Move the mouse by an offset of the specified element. Offsets are
|
|
relative to the in-view center point of the element.
|
|
|
|
:Args:
|
|
- to_element: The WebElement to move to.
|
|
- xoffset: X offset to move to, as a positive or negative integer.
|
|
- yoffset: Y offset to move to, as a positive or negative integer.
|
|
"""
|
|
|
|
self.w3c_actions.pointer_action.move_to(to_element, int(xoffset), int(yoffset))
|
|
self.w3c_actions.key_action.pause()
|
|
|
|
return self
|
|
|
|
def pause(self, seconds: float | int) -> ActionChains:
|
|
"""Pause all inputs for the specified duration in seconds."""
|
|
|
|
self.w3c_actions.pointer_action.pause(seconds)
|
|
self.w3c_actions.key_action.pause(seconds)
|
|
|
|
return self
|
|
|
|
def release(self, on_element: WebElement | None = None) -> ActionChains:
|
|
"""Releasing a held mouse button on an element.
|
|
|
|
:Args:
|
|
- on_element: The element to mouse up.
|
|
If None, releases on current mouse position.
|
|
"""
|
|
if on_element:
|
|
self.move_to_element(on_element)
|
|
|
|
self.w3c_actions.pointer_action.release()
|
|
self.w3c_actions.key_action.pause()
|
|
|
|
return self
|
|
|
|
def send_keys(self, *keys_to_send: str) -> ActionChains:
|
|
"""Sends keys to current focused element.
|
|
|
|
:Args:
|
|
- keys_to_send: The keys to send. Modifier keys constants can be found in the
|
|
'Keys' class.
|
|
"""
|
|
typing = keys_to_typing(keys_to_send)
|
|
|
|
for key in typing:
|
|
self.key_down(key)
|
|
self.key_up(key)
|
|
|
|
return self
|
|
|
|
def send_keys_to_element(self, element: WebElement, *keys_to_send: str) -> ActionChains:
|
|
"""Sends keys to an element.
|
|
|
|
:Args:
|
|
- element: The element to send keys.
|
|
- keys_to_send: The keys to send. Modifier keys constants can be found in the
|
|
'Keys' class.
|
|
"""
|
|
self.click(element)
|
|
self.send_keys(*keys_to_send)
|
|
return self
|
|
|
|
def scroll_to_element(self, element: WebElement) -> ActionChains:
|
|
"""If the element is outside the viewport, scrolls the bottom of the
|
|
element to the bottom of the viewport.
|
|
|
|
:Args:
|
|
- element: Which element to scroll into the viewport.
|
|
"""
|
|
|
|
self.w3c_actions.wheel_action.scroll(origin=element)
|
|
return self
|
|
|
|
def scroll_by_amount(self, delta_x: int, delta_y: int) -> ActionChains:
|
|
"""Scrolls by provided amounts with the origin in the top left corner
|
|
of the viewport.
|
|
|
|
:Args:
|
|
- delta_x: Distance along X axis to scroll using the wheel. A negative value scrolls left.
|
|
- delta_y: Distance along Y axis to scroll using the wheel. A negative value scrolls up.
|
|
"""
|
|
|
|
self.w3c_actions.wheel_action.scroll(delta_x=delta_x, delta_y=delta_y)
|
|
return self
|
|
|
|
def scroll_from_origin(self, scroll_origin: ScrollOrigin, delta_x: int, delta_y: int) -> ActionChains:
|
|
"""Scrolls by provided amount based on a provided origin. The scroll
|
|
origin is either the center of an element or the upper left of the
|
|
viewport plus any offsets. If the origin is an element, and the element
|
|
is not in the viewport, the bottom of the element will first be
|
|
scrolled to the bottom of the viewport.
|
|
|
|
:Args:
|
|
- origin: Where scroll originates (viewport or element center) plus provided offsets.
|
|
- delta_x: Distance along X axis to scroll using the wheel. A negative value scrolls left.
|
|
- delta_y: Distance along Y axis to scroll using the wheel. A negative value scrolls up.
|
|
|
|
:Raises: If the origin with offset is outside the viewport.
|
|
- MoveTargetOutOfBoundsException - If the origin with offset is outside the viewport.
|
|
"""
|
|
|
|
if not isinstance(scroll_origin, ScrollOrigin):
|
|
raise TypeError(f"Expected object of type ScrollOrigin, got: {type(scroll_origin)}")
|
|
|
|
self.w3c_actions.wheel_action.scroll(
|
|
origin=scroll_origin.origin,
|
|
x=scroll_origin.x_offset,
|
|
y=scroll_origin.y_offset,
|
|
delta_x=delta_x,
|
|
delta_y=delta_y,
|
|
)
|
|
return self
|
|
|
|
# Context manager so ActionChains can be used in a 'with .. as' statements.
|
|
|
|
def __enter__(self) -> ActionChains:
|
|
return self # Return created instance of self.
|
|
|
|
def __exit__(self, _type, _value, _traceback) -> None:
|
|
pass # Do nothing, does not require additional cleanup.
|