initial commit
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+379
@@ -0,0 +1,379 @@
|
||||
# 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 selenium.webdriver.common.actions.action_builder import ActionBuilder
|
||||
from selenium.webdriver.common.actions.key_input import KeyInput
|
||||
from selenium.webdriver.common.actions.pointer_input import PointerInput
|
||||
from selenium.webdriver.common.actions.wheel_input import ScrollOrigin, WheelInput
|
||||
from selenium.webdriver.common.utils import keys_to_typing
|
||||
from selenium.webdriver.remote.webelement import WebElement
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
|
||||
|
||||
class ActionChains:
|
||||
"""Automate low-level interactions like mouse movements, button actions, key presses, and context menus.
|
||||
|
||||
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[PointerInput | KeyInput | WheelInput] | 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
|
||||
devices: Optional list of input devices (PointerInput, KeyInput, WheelInput) to use.
|
||||
If not provided, default devices will be created.
|
||||
"""
|
||||
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:
|
||||
"""Clear actions 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:
|
||||
"""Hold down the left mouse button on an element, then move to target and release.
|
||||
|
||||
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:
|
||||
"""Hold down the left mouse button on an element, then move by offset and release.
|
||||
|
||||
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:
|
||||
"""Send a key press only without releasing it (modifier keys only).
|
||||
|
||||
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 to an element with the specified offsets.
|
||||
|
||||
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(int(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:
|
||||
"""Scroll the element into the viewport if it's outside it.
|
||||
|
||||
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:
|
||||
"""Scroll by a provided amount with the origin in the top left corner.
|
||||
|
||||
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:
|
||||
"""Scroll by a provided amount based on a scroll origin (element or viewport).
|
||||
|
||||
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:
|
||||
scroll_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:
|
||||
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.
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+168
@@ -0,0 +1,168 @@
|
||||
# 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.
|
||||
|
||||
|
||||
from typing import Any, Union
|
||||
|
||||
from selenium.webdriver.common.actions import interaction
|
||||
from selenium.webdriver.common.actions.key_actions import KeyActions
|
||||
from selenium.webdriver.common.actions.key_input import KeyInput
|
||||
from selenium.webdriver.common.actions.pointer_actions import PointerActions
|
||||
from selenium.webdriver.common.actions.pointer_input import PointerInput
|
||||
from selenium.webdriver.common.actions.wheel_actions import WheelActions
|
||||
from selenium.webdriver.common.actions.wheel_input import WheelInput
|
||||
from selenium.webdriver.remote.command import Command
|
||||
|
||||
|
||||
class ActionBuilder:
|
||||
def __init__(
|
||||
self,
|
||||
driver,
|
||||
mouse: PointerInput | None = None,
|
||||
wheel: WheelInput | None = None,
|
||||
keyboard: KeyInput | None = None,
|
||||
duration: int = 250,
|
||||
) -> None:
|
||||
mouse = mouse or PointerInput(interaction.POINTER_MOUSE, "mouse")
|
||||
keyboard = keyboard or KeyInput(interaction.KEY)
|
||||
wheel = wheel or WheelInput(interaction.WHEEL)
|
||||
self.devices: list[PointerInput | KeyInput | WheelInput] = [mouse, keyboard, wheel]
|
||||
self._key_action = KeyActions(keyboard)
|
||||
self._pointer_action = PointerActions(mouse, duration=duration)
|
||||
self._wheel_action = WheelActions(wheel)
|
||||
self.driver = driver
|
||||
|
||||
def get_device_with(self, name: str) -> Union["WheelInput", "PointerInput", "KeyInput"] | None:
|
||||
"""Get the device with the given name.
|
||||
|
||||
Args:
|
||||
name: The name of the device to get.
|
||||
|
||||
Returns:
|
||||
The device with the given name, or None if not found.
|
||||
"""
|
||||
return next(filter(lambda x: x == name, self.devices), None)
|
||||
|
||||
@property
|
||||
def pointer_inputs(self) -> list[PointerInput]:
|
||||
return [device for device in self.devices if isinstance(device, PointerInput)]
|
||||
|
||||
@property
|
||||
def key_inputs(self) -> list[KeyInput]:
|
||||
return [device for device in self.devices if isinstance(device, KeyInput)]
|
||||
|
||||
@property
|
||||
def key_action(self) -> KeyActions:
|
||||
return self._key_action
|
||||
|
||||
@property
|
||||
def pointer_action(self) -> PointerActions:
|
||||
return self._pointer_action
|
||||
|
||||
@property
|
||||
def wheel_action(self) -> WheelActions:
|
||||
return self._wheel_action
|
||||
|
||||
def add_key_input(self, name: str) -> KeyInput:
|
||||
"""Add a new key input device to the action builder.
|
||||
|
||||
Args:
|
||||
name: The name of the key input device.
|
||||
|
||||
Returns:
|
||||
The newly created key input device.
|
||||
|
||||
Example:
|
||||
>>> action_builder = ActionBuilder(driver)
|
||||
>>> action_builder.add_key_input(name="keyboard2")
|
||||
"""
|
||||
new_input = KeyInput(name)
|
||||
self._add_input(new_input)
|
||||
return new_input
|
||||
|
||||
def add_pointer_input(self, kind: str, name: str) -> PointerInput:
|
||||
"""Add a new pointer input device to the action builder.
|
||||
|
||||
Args:
|
||||
kind: The kind of pointer input device. Valid values are "mouse",
|
||||
"touch", or "pen".
|
||||
name: The name of the pointer input device.
|
||||
|
||||
Returns:
|
||||
The newly created pointer input device.
|
||||
|
||||
Example:
|
||||
>>> action_builder = ActionBuilder(driver)
|
||||
>>> action_builder.add_pointer_input(kind="mouse", name="mouse")
|
||||
"""
|
||||
new_input = PointerInput(kind, name)
|
||||
self._add_input(new_input)
|
||||
return new_input
|
||||
|
||||
def add_wheel_input(self, name: str) -> WheelInput:
|
||||
"""Add a new wheel input device to the action builder.
|
||||
|
||||
Args:
|
||||
name: The name of the wheel input device.
|
||||
|
||||
Returns:
|
||||
The newly created wheel input device.
|
||||
|
||||
Example:
|
||||
>>> action_builder = ActionBuilder(driver)
|
||||
>>> action_builder.add_wheel_input(name="wheel2")
|
||||
"""
|
||||
new_input = WheelInput(name)
|
||||
self._add_input(new_input)
|
||||
return new_input
|
||||
|
||||
def perform(self) -> None:
|
||||
"""Performs all stored actions.
|
||||
|
||||
Example:
|
||||
>>> action_builder = ActionBuilder(driver)
|
||||
>>> keyboard = action_builder.key_input
|
||||
>>> el = driver.find_element(id: "some_id")
|
||||
>>> action_builder.click(el).pause(keyboard).pause(keyboard).pause(keyboard).send_keys("keys").perform()
|
||||
"""
|
||||
enc: dict[str, list[Any]] = {"actions": []}
|
||||
for device in self.devices:
|
||||
encoded = device.encode()
|
||||
if encoded["actions"]:
|
||||
enc["actions"].append(encoded)
|
||||
device.actions = []
|
||||
self.driver.execute(Command.W3C_ACTIONS, enc)
|
||||
|
||||
def clear_actions(self) -> None:
|
||||
"""Clears actions that are already stored on the remote end.
|
||||
|
||||
Example:
|
||||
>>> action_builder = ActionBuilder(driver)
|
||||
>>> keyboard = action_builder.key_input
|
||||
>>> el = driver.find_element(By.ID, "some_id")
|
||||
>>> action_builder.click(el).pause(keyboard).pause(keyboard).pause(keyboard).send_keys("keys")
|
||||
>>> action_builder.clear_actions()
|
||||
"""
|
||||
self.driver.execute(Command.W3C_CLEAR_ACTIONS)
|
||||
|
||||
def _add_input(self, new_input: KeyInput | PointerInput | WheelInput) -> None:
|
||||
"""Add a new input device to the action builder.
|
||||
|
||||
Args:
|
||||
new_input: The new input device to add.
|
||||
"""
|
||||
self.devices.append(new_input)
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# 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.
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
|
||||
class InputDevice:
|
||||
"""Describes the input device being used for the action."""
|
||||
|
||||
def __init__(self, name: str | None = None):
|
||||
self.name = name or uuid.uuid4()
|
||||
self.actions: list[Any] = []
|
||||
|
||||
def add_action(self, action: Any) -> None:
|
||||
self.actions.append(action)
|
||||
|
||||
def clear_actions(self) -> None:
|
||||
self.actions = []
|
||||
|
||||
def create_pause(self, duration: float = 0) -> None:
|
||||
pass
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# 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.
|
||||
|
||||
from selenium.webdriver.common.actions.input_device import InputDevice
|
||||
|
||||
KEY = "key"
|
||||
POINTER = "pointer"
|
||||
NONE = "none"
|
||||
WHEEL = "wheel"
|
||||
SOURCE_TYPES = {KEY, POINTER, WHEEL, NONE}
|
||||
|
||||
POINTER_MOUSE = "mouse"
|
||||
POINTER_TOUCH = "touch"
|
||||
POINTER_PEN = "pen"
|
||||
|
||||
POINTER_KINDS = {POINTER_MOUSE, POINTER_TOUCH, POINTER_PEN}
|
||||
|
||||
|
||||
class Interaction:
|
||||
PAUSE = "pause"
|
||||
|
||||
def __init__(self, source: InputDevice) -> None:
|
||||
self.source = source
|
||||
|
||||
|
||||
class Pause(Interaction):
|
||||
def __init__(self, source, duration: float = 0) -> None:
|
||||
super().__init__(source)
|
||||
self.duration = duration
|
||||
|
||||
def encode(self) -> dict[str, str | int]:
|
||||
return {"type": self.PAUSE, "duration": int(self.duration * 1000)}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# 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.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from selenium.webdriver.common.actions.interaction import KEY, Interaction
|
||||
from selenium.webdriver.common.actions.key_input import KeyInput
|
||||
from selenium.webdriver.common.actions.pointer_input import PointerInput
|
||||
from selenium.webdriver.common.actions.wheel_input import WheelInput
|
||||
from selenium.webdriver.common.utils import keys_to_typing
|
||||
|
||||
|
||||
class KeyActions(Interaction):
|
||||
def __init__(self, source: KeyInput | PointerInput | WheelInput | None = None) -> None:
|
||||
if source is None:
|
||||
source = KeyInput(KEY)
|
||||
self.input_source = source
|
||||
super().__init__(source)
|
||||
|
||||
def key_down(self, letter: str) -> KeyActions:
|
||||
return self._key_action("create_key_down", letter)
|
||||
|
||||
def key_up(self, letter: str) -> KeyActions:
|
||||
return self._key_action("create_key_up", letter)
|
||||
|
||||
def pause(self, duration: int = 0) -> KeyActions:
|
||||
return self._key_action("create_pause", duration)
|
||||
|
||||
def send_keys(self, text: str | list) -> KeyActions:
|
||||
if not isinstance(text, list):
|
||||
text = keys_to_typing(text)
|
||||
for letter in text:
|
||||
self.key_down(letter)
|
||||
self.key_up(letter)
|
||||
return self
|
||||
|
||||
def _key_action(self, action: str, letter) -> KeyActions:
|
||||
meth = getattr(self.source, action)
|
||||
meth(letter)
|
||||
return self
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# 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.
|
||||
from selenium.webdriver.common.actions import interaction
|
||||
from selenium.webdriver.common.actions.input_device import InputDevice
|
||||
from selenium.webdriver.common.actions.interaction import Interaction, Pause
|
||||
|
||||
|
||||
class KeyInput(InputDevice):
|
||||
def __init__(self, name: str) -> None:
|
||||
super().__init__()
|
||||
self.name = name
|
||||
self.type = interaction.KEY
|
||||
|
||||
def encode(self) -> dict:
|
||||
return {"type": self.type, "id": self.name, "actions": [acts.encode() for acts in self.actions]}
|
||||
|
||||
def create_key_down(self, key) -> None:
|
||||
self.add_action(TypingInteraction(self, "keyDown", key))
|
||||
|
||||
def create_key_up(self, key) -> None:
|
||||
self.add_action(TypingInteraction(self, "keyUp", key))
|
||||
|
||||
def create_pause(self, pause_duration: float = 0) -> None:
|
||||
self.add_action(Pause(self, pause_duration))
|
||||
|
||||
|
||||
class TypingInteraction(Interaction):
|
||||
def __init__(self, source, type_, key) -> None:
|
||||
super().__init__(source)
|
||||
self.type = type_
|
||||
self.key = key
|
||||
|
||||
def encode(self) -> dict:
|
||||
return {"type": self.type, "value": self.key}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# 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.
|
||||
|
||||
|
||||
class MouseButton:
|
||||
LEFT = 0
|
||||
MIDDLE = 1
|
||||
RIGHT = 2
|
||||
BACK = 3
|
||||
FORWARD = 4
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
# 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.
|
||||
|
||||
from selenium.webdriver.common.actions import interaction
|
||||
from selenium.webdriver.common.actions.interaction import Interaction
|
||||
from selenium.webdriver.common.actions.mouse_button import MouseButton
|
||||
from selenium.webdriver.common.actions.pointer_input import PointerInput
|
||||
from selenium.webdriver.remote.webelement import WebElement
|
||||
|
||||
|
||||
class PointerActions(Interaction):
|
||||
def __init__(self, source: PointerInput | None = None, duration: int = 250):
|
||||
"""Initialize a new PointerActions instance.
|
||||
|
||||
Args:
|
||||
source: Optional PointerInput instance. If not provided, a default
|
||||
mouse PointerInput will be created.
|
||||
duration: Override the default 250 msecs of DEFAULT_MOVE_DURATION
|
||||
in the source.
|
||||
"""
|
||||
if source is None:
|
||||
source = PointerInput(interaction.POINTER_MOUSE, "mouse")
|
||||
self.source = source
|
||||
self._duration = duration
|
||||
super().__init__(source)
|
||||
|
||||
def pointer_down(
|
||||
self,
|
||||
button=MouseButton.LEFT,
|
||||
width=None,
|
||||
height=None,
|
||||
pressure=None,
|
||||
tangential_pressure=None,
|
||||
tilt_x=None,
|
||||
tilt_y=None,
|
||||
twist=None,
|
||||
altitude_angle=None,
|
||||
azimuth_angle=None,
|
||||
):
|
||||
self._button_action(
|
||||
"create_pointer_down",
|
||||
button=button,
|
||||
width=width,
|
||||
height=height,
|
||||
pressure=pressure,
|
||||
tangential_pressure=tangential_pressure,
|
||||
tilt_x=tilt_x,
|
||||
tilt_y=tilt_y,
|
||||
twist=twist,
|
||||
altitude_angle=altitude_angle,
|
||||
azimuth_angle=azimuth_angle,
|
||||
)
|
||||
return self
|
||||
|
||||
def pointer_up(self, button=MouseButton.LEFT):
|
||||
self._button_action("create_pointer_up", button=button)
|
||||
return self
|
||||
|
||||
def move_to(
|
||||
self,
|
||||
element,
|
||||
x=0,
|
||||
y=0,
|
||||
width=None,
|
||||
height=None,
|
||||
pressure=None,
|
||||
tangential_pressure=None,
|
||||
tilt_x=None,
|
||||
tilt_y=None,
|
||||
twist=None,
|
||||
altitude_angle=None,
|
||||
azimuth_angle=None,
|
||||
):
|
||||
if not isinstance(element, WebElement):
|
||||
raise AttributeError("move_to requires a WebElement")
|
||||
|
||||
self.source.create_pointer_move(
|
||||
origin=element,
|
||||
duration=self._duration,
|
||||
x=int(x),
|
||||
y=int(y),
|
||||
width=width,
|
||||
height=height,
|
||||
pressure=pressure,
|
||||
tangential_pressure=tangential_pressure,
|
||||
tilt_x=tilt_x,
|
||||
tilt_y=tilt_y,
|
||||
twist=twist,
|
||||
altitude_angle=altitude_angle,
|
||||
azimuth_angle=azimuth_angle,
|
||||
)
|
||||
return self
|
||||
|
||||
def move_by(
|
||||
self,
|
||||
x,
|
||||
y,
|
||||
width=None,
|
||||
height=None,
|
||||
pressure=None,
|
||||
tangential_pressure=None,
|
||||
tilt_x=None,
|
||||
tilt_y=None,
|
||||
twist=None,
|
||||
altitude_angle=None,
|
||||
azimuth_angle=None,
|
||||
):
|
||||
self.source.create_pointer_move(
|
||||
origin=interaction.POINTER,
|
||||
duration=self._duration,
|
||||
x=int(x),
|
||||
y=int(y),
|
||||
width=width,
|
||||
height=height,
|
||||
pressure=pressure,
|
||||
tangential_pressure=tangential_pressure,
|
||||
tilt_x=tilt_x,
|
||||
tilt_y=tilt_y,
|
||||
twist=twist,
|
||||
altitude_angle=altitude_angle,
|
||||
azimuth_angle=azimuth_angle,
|
||||
)
|
||||
return self
|
||||
|
||||
def move_to_location(
|
||||
self,
|
||||
x,
|
||||
y,
|
||||
width=None,
|
||||
height=None,
|
||||
pressure=None,
|
||||
tangential_pressure=None,
|
||||
tilt_x=None,
|
||||
tilt_y=None,
|
||||
twist=None,
|
||||
altitude_angle=None,
|
||||
azimuth_angle=None,
|
||||
):
|
||||
self.source.create_pointer_move(
|
||||
origin="viewport",
|
||||
duration=self._duration,
|
||||
x=int(x),
|
||||
y=int(y),
|
||||
width=width,
|
||||
height=height,
|
||||
pressure=pressure,
|
||||
tangential_pressure=tangential_pressure,
|
||||
tilt_x=tilt_x,
|
||||
tilt_y=tilt_y,
|
||||
twist=twist,
|
||||
altitude_angle=altitude_angle,
|
||||
azimuth_angle=azimuth_angle,
|
||||
)
|
||||
return self
|
||||
|
||||
def click(self, element: WebElement | None = None, button=MouseButton.LEFT):
|
||||
if element:
|
||||
self.move_to(element)
|
||||
self.pointer_down(button)
|
||||
self.pointer_up(button)
|
||||
return self
|
||||
|
||||
def context_click(self, element: WebElement | None = None):
|
||||
return self.click(element=element, button=MouseButton.RIGHT)
|
||||
|
||||
def click_and_hold(self, element: WebElement | None = None, button=MouseButton.LEFT):
|
||||
if element:
|
||||
self.move_to(element)
|
||||
self.pointer_down(button=button)
|
||||
return self
|
||||
|
||||
def release(self, button=MouseButton.LEFT):
|
||||
self.pointer_up(button=button)
|
||||
return self
|
||||
|
||||
def double_click(self, element: WebElement | None = None):
|
||||
if element:
|
||||
self.move_to(element)
|
||||
self.pointer_down(MouseButton.LEFT)
|
||||
self.pointer_up(MouseButton.LEFT)
|
||||
self.pointer_down(MouseButton.LEFT)
|
||||
self.pointer_up(MouseButton.LEFT)
|
||||
return self
|
||||
|
||||
def pause(self, duration: float = 0):
|
||||
self.source.create_pause(duration)
|
||||
return self
|
||||
|
||||
def _button_action(self, action, **kwargs):
|
||||
meth = getattr(self.source, action)
|
||||
meth(**kwargs)
|
||||
return self
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
# 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.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from selenium.common.exceptions import InvalidArgumentException
|
||||
from selenium.webdriver.common.actions.input_device import InputDevice
|
||||
from selenium.webdriver.common.actions.interaction import POINTER, POINTER_KINDS
|
||||
from selenium.webdriver.remote.webelement import WebElement
|
||||
|
||||
|
||||
class PointerInput(InputDevice):
|
||||
DEFAULT_MOVE_DURATION = 250
|
||||
|
||||
def __init__(self, kind, name):
|
||||
super().__init__()
|
||||
if kind not in POINTER_KINDS:
|
||||
raise InvalidArgumentException(f"Invalid PointerInput kind '{kind}'")
|
||||
self.type = POINTER
|
||||
self.kind = kind
|
||||
self.name = name
|
||||
|
||||
def create_pointer_move(
|
||||
self,
|
||||
duration=DEFAULT_MOVE_DURATION,
|
||||
x: float = 0,
|
||||
y: float = 0,
|
||||
origin: WebElement | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
action = {"type": "pointerMove", "duration": duration, "x": x, "y": y, **kwargs}
|
||||
if isinstance(origin, WebElement):
|
||||
action["origin"] = {"element-6066-11e4-a52e-4f735466cecf": origin.id}
|
||||
elif origin is not None:
|
||||
action["origin"] = origin
|
||||
self.add_action(self._convert_keys(action))
|
||||
|
||||
def create_pointer_down(self, **kwargs):
|
||||
data = {"type": "pointerDown", "duration": 0, **kwargs}
|
||||
self.add_action(self._convert_keys(data))
|
||||
|
||||
def create_pointer_up(self, button):
|
||||
self.add_action({"type": "pointerUp", "duration": 0, "button": button})
|
||||
|
||||
def create_pointer_cancel(self):
|
||||
self.add_action({"type": "pointerCancel"})
|
||||
|
||||
def create_pause(self, pause_duration: int | float = 0) -> None:
|
||||
self.add_action({"type": "pause", "duration": int(pause_duration * 1000)})
|
||||
|
||||
def encode(self):
|
||||
return {"type": self.type, "parameters": {"pointerType": self.kind}, "id": self.name, "actions": self.actions}
|
||||
|
||||
def _convert_keys(self, actions: dict[str, Any]):
|
||||
out = {}
|
||||
for k, v in actions.items():
|
||||
if v is None:
|
||||
continue
|
||||
if k in ("x", "y"):
|
||||
out[k] = int(v)
|
||||
continue
|
||||
splits = k.split("_")
|
||||
new_key = splits[0] + "".join(v.title() for v in splits[1:])
|
||||
out[new_key] = v
|
||||
return out
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# 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.
|
||||
|
||||
|
||||
from selenium.webdriver.common.actions.interaction import WHEEL, Interaction
|
||||
from selenium.webdriver.common.actions.wheel_input import WheelInput
|
||||
|
||||
|
||||
class WheelActions(Interaction):
|
||||
def __init__(self, source: WheelInput | None = None):
|
||||
if source is None:
|
||||
source = WheelInput(WHEEL)
|
||||
super().__init__(source)
|
||||
|
||||
def pause(self, duration: float = 0):
|
||||
self.source.create_pause(duration)
|
||||
return self
|
||||
|
||||
def scroll(self, x=0, y=0, delta_x=0, delta_y=0, duration=0, origin="viewport"):
|
||||
self.source.create_scroll(x, y, delta_x, delta_y, duration, origin)
|
||||
return self
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
# 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.
|
||||
|
||||
from selenium.webdriver.common.actions import interaction
|
||||
from selenium.webdriver.common.actions.input_device import InputDevice
|
||||
from selenium.webdriver.remote.webelement import WebElement
|
||||
|
||||
|
||||
class ScrollOrigin:
|
||||
def __init__(self, origin: str | WebElement, x_offset: int, y_offset: int) -> None:
|
||||
self._origin = origin
|
||||
self._x_offset = x_offset
|
||||
self._y_offset = y_offset
|
||||
|
||||
@classmethod
|
||||
def from_element(cls, element: WebElement, x_offset: int = 0, y_offset: int = 0):
|
||||
return cls(element, x_offset, y_offset)
|
||||
|
||||
@classmethod
|
||||
def from_viewport(cls, x_offset: int = 0, y_offset: int = 0):
|
||||
return cls("viewport", x_offset, y_offset)
|
||||
|
||||
@property
|
||||
def origin(self) -> str | WebElement:
|
||||
return self._origin
|
||||
|
||||
@property
|
||||
def x_offset(self) -> int:
|
||||
return self._x_offset
|
||||
|
||||
@property
|
||||
def y_offset(self) -> int:
|
||||
return self._y_offset
|
||||
|
||||
|
||||
class WheelInput(InputDevice):
|
||||
def __init__(self, name) -> None:
|
||||
super().__init__(name=name)
|
||||
self.name = name
|
||||
self.type = interaction.WHEEL
|
||||
|
||||
def encode(self) -> dict:
|
||||
return {"type": self.type, "id": self.name, "actions": self.actions}
|
||||
|
||||
def create_scroll(self, x: int, y: int, delta_x: int, delta_y: int, duration: int, origin) -> None:
|
||||
if isinstance(origin, WebElement):
|
||||
origin = {"element-6066-11e4-a52e-4f735466cecf": origin.id}
|
||||
self.add_action(
|
||||
{
|
||||
"type": "scroll",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"deltaX": delta_x,
|
||||
"deltaY": delta_y,
|
||||
"duration": duration,
|
||||
"origin": origin,
|
||||
}
|
||||
)
|
||||
|
||||
def create_pause(self, pause_duration: int | float = 0) -> None:
|
||||
self.add_action({"type": "pause", "duration": int(pause_duration * 1000)})
|
||||
@@ -0,0 +1,78 @@
|
||||
# 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 Alert implementation."""
|
||||
|
||||
from selenium.webdriver.common.utils import keys_to_typing
|
||||
from selenium.webdriver.remote.command import Command
|
||||
|
||||
|
||||
class Alert:
|
||||
"""Allows to work with alerts.
|
||||
|
||||
Use this class to interact with alert prompts. It contains methods for dismissing,
|
||||
accepting, inputting, and getting text from alert prompts.
|
||||
|
||||
Accepting / Dismissing alert prompts::
|
||||
|
||||
Alert(driver).accept()
|
||||
Alert(driver).dismiss()
|
||||
|
||||
Inputting a value into an alert prompt::
|
||||
|
||||
name_prompt = Alert(driver)
|
||||
name_prompt.send_keys("Willian Shakesphere")
|
||||
name_prompt.accept()
|
||||
|
||||
|
||||
Reading a the text of a prompt for verification::
|
||||
|
||||
alert_text = Alert(driver).text
|
||||
self.assertEqual("Do you wish to quit?", alert_text)
|
||||
"""
|
||||
|
||||
def __init__(self, driver) -> None:
|
||||
"""Creates a new Alert.
|
||||
|
||||
Args:
|
||||
driver: The WebDriver instance which performs user actions.
|
||||
"""
|
||||
self.driver = driver
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""Gets the text of the Alert."""
|
||||
return self.driver.execute(Command.W3C_GET_ALERT_TEXT)["value"]
|
||||
|
||||
def dismiss(self) -> None:
|
||||
"""Dismisses the alert available."""
|
||||
self.driver.execute(Command.W3C_DISMISS_ALERT)
|
||||
|
||||
def accept(self) -> None:
|
||||
"""Accepts the alert available.
|
||||
|
||||
Example:
|
||||
Alert(driver).accept() # Confirm a alert dialog.
|
||||
"""
|
||||
self.driver.execute(Command.W3C_ACCEPT_ALERT)
|
||||
|
||||
def send_keys(self, keysToSend: str) -> None:
|
||||
"""Send Keys to the Alert.
|
||||
|
||||
Args:
|
||||
keysToSend: The text to be sent to Alert.
|
||||
"""
|
||||
self.driver.execute(Command.W3C_SET_ALERT_VALUE, {"value": keys_to_typing(keysToSend), "text": keysToSend})
|
||||
+704
@@ -0,0 +1,704 @@
|
||||
# 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.
|
||||
|
||||
"""APIRequestContext for making HTTP requests with browser cookie synchronization."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import pathlib
|
||||
import time
|
||||
import urllib.parse
|
||||
from email.utils import parsedate_to_datetime
|
||||
from http.client import responses as http_status_phrases
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import urllib3
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class APIRequestFailure(Exception):
|
||||
"""Raised when an API request returns a non-2xx status and fail_on_status_code is True.
|
||||
|
||||
Attributes:
|
||||
response: The APIResponse that triggered the failure.
|
||||
"""
|
||||
|
||||
def __init__(self, response: "APIResponse") -> None:
|
||||
self.response = response
|
||||
super().__init__(f"{response.status} {response.status_text}: {response.url}")
|
||||
|
||||
|
||||
class APIResponse:
|
||||
"""Represents an HTTP response from an API request.
|
||||
|
||||
Attributes:
|
||||
status: HTTP status code.
|
||||
status_text: HTTP status text.
|
||||
headers: Response headers as a dict.
|
||||
url: The request URL.
|
||||
"""
|
||||
|
||||
def __init__(self, status: int, status_text: str, headers: dict[str, str], url: str, body: bytes) -> None:
|
||||
self.status = status
|
||||
self.status_text = status_text
|
||||
self.headers = headers
|
||||
self.url = url
|
||||
self._body = body
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""Whether the response status is in the 200-299 range."""
|
||||
return 200 <= self.status <= 299
|
||||
|
||||
def json(self) -> Any:
|
||||
"""Parse the response body as JSON.
|
||||
|
||||
Returns:
|
||||
The parsed JSON object.
|
||||
"""
|
||||
return json.loads(self._body)
|
||||
|
||||
def text(self) -> str:
|
||||
"""Decode the response body as UTF-8 text.
|
||||
|
||||
Returns:
|
||||
The response body as a string.
|
||||
"""
|
||||
return self._body.decode("utf-8")
|
||||
|
||||
def body(self) -> bytes:
|
||||
"""Return the raw response body bytes.
|
||||
|
||||
Returns:
|
||||
The response body as bytes.
|
||||
"""
|
||||
return self._body
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Free the response body memory."""
|
||||
self._body = b""
|
||||
|
||||
|
||||
def _cookie_matches(cookie: dict, url: str, default_domain: str = "") -> bool:
|
||||
"""Check if a browser cookie should be sent with a request to the given URL.
|
||||
|
||||
Evaluates expiry, domain, path, and secure attribute matching per RFC 6265.
|
||||
|
||||
Args:
|
||||
cookie: A cookie dict from driver.get_cookies().
|
||||
url: The target request URL.
|
||||
default_domain: Fallback domain for host-only cookies (no domain attribute).
|
||||
When a cookie has no domain, it only matches if the request hostname
|
||||
equals this value. If empty and cookie has no domain, the cookie is skipped.
|
||||
|
||||
Returns:
|
||||
True if the cookie matches the URL.
|
||||
"""
|
||||
# Expiry check — skip expired cookies
|
||||
expiry = cookie.get("expiry")
|
||||
if expiry is not None and expiry <= int(time.time()):
|
||||
return False
|
||||
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
hostname = parsed.hostname or ""
|
||||
path = parsed.path or "/"
|
||||
scheme = parsed.scheme or "http"
|
||||
|
||||
# Domain matching (RFC 6265 section 5.1.3)
|
||||
cookie_domain = cookie.get("domain", "")
|
||||
if not cookie_domain:
|
||||
# Host-only cookie — must match the origin host exactly
|
||||
if not default_domain or hostname != default_domain:
|
||||
return False
|
||||
elif cookie_domain.startswith("."):
|
||||
# .example.com matches example.com and sub.example.com
|
||||
if not (hostname == cookie_domain[1:] or hostname.endswith(cookie_domain)):
|
||||
return False
|
||||
else:
|
||||
if hostname != cookie_domain:
|
||||
return False
|
||||
|
||||
# Path matching (RFC 6265 section 5.1.4)
|
||||
cookie_path = cookie.get("path", "/")
|
||||
if cookie_path == "/":
|
||||
pass # root path matches everything
|
||||
elif path != cookie_path and not path.startswith(cookie_path + "/"):
|
||||
return False
|
||||
|
||||
# Secure matching
|
||||
if cookie.get("secure", False) and scheme != "https":
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _parse_set_cookie(header_value: str) -> dict:
|
||||
"""Parse a single Set-Cookie header value into a cookie dict.
|
||||
|
||||
Uses manual parsing instead of http.cookies.SimpleCookie which is too
|
||||
strict for real-world Set-Cookie headers.
|
||||
|
||||
Args:
|
||||
header_value: The Set-Cookie header string.
|
||||
|
||||
Returns:
|
||||
A dict with cookie attributes suitable for driver.add_cookie().
|
||||
"""
|
||||
parts = header_value.split(";")
|
||||
name_value = parts[0].strip()
|
||||
eq_idx = name_value.find("=")
|
||||
if eq_idx == -1:
|
||||
return {}
|
||||
name = name_value[:eq_idx].strip()
|
||||
value = name_value[eq_idx + 1 :].strip()
|
||||
|
||||
cookie: dict[str, Any] = {"name": name, "value": value}
|
||||
has_max_age = False
|
||||
|
||||
for part in parts[1:]:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if "=" in part:
|
||||
attr_name, attr_value = part.split("=", 1)
|
||||
attr_name = attr_name.strip().lower()
|
||||
attr_value = attr_value.strip()
|
||||
else:
|
||||
attr_name = part.strip().lower()
|
||||
attr_value = ""
|
||||
|
||||
if attr_name == "domain":
|
||||
cookie["domain"] = attr_value
|
||||
elif attr_name == "path":
|
||||
cookie["path"] = attr_value
|
||||
elif attr_name == "secure":
|
||||
cookie["secure"] = True
|
||||
elif attr_name == "httponly":
|
||||
cookie["httpOnly"] = True
|
||||
elif attr_name == "samesite":
|
||||
cookie["sameSite"] = attr_value
|
||||
elif attr_name == "max-age":
|
||||
try:
|
||||
max_age = int(attr_value)
|
||||
cookie["expiry"] = int(time.time()) + max_age
|
||||
has_max_age = True
|
||||
except ValueError:
|
||||
pass
|
||||
elif attr_name == "expires" and not has_max_age:
|
||||
# RFC 6265 §5.3: Max-Age takes precedence over Expires
|
||||
try:
|
||||
dt = parsedate_to_datetime(attr_value)
|
||||
cookie["expiry"] = int(dt.timestamp())
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return cookie
|
||||
|
||||
|
||||
def _get_set_cookie_headers(resp: urllib3.BaseHTTPResponse) -> list[str]:
|
||||
"""Extract all Set-Cookie header values from a urllib3 response.
|
||||
|
||||
Args:
|
||||
resp: The urllib3 HTTP response.
|
||||
|
||||
Returns:
|
||||
A list of Set-Cookie header strings.
|
||||
"""
|
||||
if hasattr(resp.headers, "getlist"):
|
||||
headers = resp.headers.getlist("Set-Cookie")
|
||||
if headers:
|
||||
return headers
|
||||
sc = resp.headers.get("Set-Cookie")
|
||||
return [sc] if sc else []
|
||||
|
||||
|
||||
def _resolve_redirect_url(resp: urllib3.BaseHTTPResponse, original_url: str) -> str:
|
||||
"""Return the final URL after any redirects.
|
||||
|
||||
urllib3's retry history records each hop. When redirects occurred,
|
||||
the last entry's redirect_location resolved against its URL gives
|
||||
the final destination. When no redirects occurred, the original
|
||||
request URL is returned unchanged.
|
||||
"""
|
||||
history = resp.retries.history if resp.retries else ()
|
||||
if history:
|
||||
last = history[-1]
|
||||
if last.url and last.redirect_location:
|
||||
return urllib.parse.urljoin(last.url, last.redirect_location)
|
||||
return original_url
|
||||
|
||||
|
||||
class _BaseRequestContext:
|
||||
"""Base class with shared HTTP request logic for API request contexts."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "",
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
timeout: float = 30.0,
|
||||
max_redirects: int = 10,
|
||||
fail_on_status_code: bool = False,
|
||||
) -> None:
|
||||
self._base_url = base_url
|
||||
self._extra_headers = extra_headers or {}
|
||||
self._timeout = timeout
|
||||
self._max_redirects = max_redirects
|
||||
self._fail_on_status_code = fail_on_status_code
|
||||
self._pool = urllib3.PoolManager()
|
||||
|
||||
def get(self, url: str, **kwargs: Any) -> APIResponse:
|
||||
"""Send a GET request.
|
||||
|
||||
Args:
|
||||
url: The request URL (absolute or relative to base_url).
|
||||
**kwargs: Optional arguments: headers, params, timeout, max_redirects, fail_on_status_code.
|
||||
|
||||
Returns:
|
||||
An APIResponse object.
|
||||
"""
|
||||
return self._fetch(url, "GET", **kwargs)
|
||||
|
||||
def post(self, url: str, **kwargs: Any) -> APIResponse:
|
||||
"""Send a POST request.
|
||||
|
||||
Args:
|
||||
url: The request URL (absolute or relative to base_url).
|
||||
**kwargs: Optional arguments: headers, params, data, form,
|
||||
json_data, timeout, max_redirects, fail_on_status_code.
|
||||
|
||||
Returns:
|
||||
An APIResponse object.
|
||||
"""
|
||||
return self._fetch(url, "POST", **kwargs)
|
||||
|
||||
def put(self, url: str, **kwargs: Any) -> APIResponse:
|
||||
"""Send a PUT request.
|
||||
|
||||
Args:
|
||||
url: The request URL (absolute or relative to base_url).
|
||||
**kwargs: Optional arguments: headers, params, data, form,
|
||||
json_data, timeout, max_redirects, fail_on_status_code.
|
||||
|
||||
Returns:
|
||||
An APIResponse object.
|
||||
"""
|
||||
return self._fetch(url, "PUT", **kwargs)
|
||||
|
||||
def patch(self, url: str, **kwargs: Any) -> APIResponse:
|
||||
"""Send a PATCH request.
|
||||
|
||||
Args:
|
||||
url: The request URL (absolute or relative to base_url).
|
||||
**kwargs: Optional arguments: headers, params, data, form,
|
||||
json_data, timeout, max_redirects, fail_on_status_code.
|
||||
|
||||
Returns:
|
||||
An APIResponse object.
|
||||
"""
|
||||
return self._fetch(url, "PATCH", **kwargs)
|
||||
|
||||
def delete(self, url: str, **kwargs: Any) -> APIResponse:
|
||||
"""Send a DELETE request.
|
||||
|
||||
Args:
|
||||
url: The request URL (absolute or relative to base_url).
|
||||
**kwargs: Optional arguments: headers, params, data, form,
|
||||
json_data, timeout, max_redirects, fail_on_status_code.
|
||||
|
||||
Returns:
|
||||
An APIResponse object.
|
||||
"""
|
||||
return self._fetch(url, "DELETE", **kwargs)
|
||||
|
||||
def head(self, url: str, **kwargs: Any) -> APIResponse:
|
||||
"""Send a HEAD request.
|
||||
|
||||
Args:
|
||||
url: The request URL (absolute or relative to base_url).
|
||||
**kwargs: Optional arguments: headers, params, timeout,
|
||||
max_redirects, fail_on_status_code.
|
||||
|
||||
Returns:
|
||||
An APIResponse object.
|
||||
"""
|
||||
return self._fetch(url, "HEAD", **kwargs)
|
||||
|
||||
def fetch(self, url: str, method: str = "GET", **kwargs: Any) -> APIResponse:
|
||||
"""Send an HTTP request with a custom method.
|
||||
|
||||
Args:
|
||||
url: The request URL (absolute or relative to base_url).
|
||||
method: The HTTP method to use.
|
||||
**kwargs: Optional arguments: headers, params, data, form,
|
||||
json_data, timeout, max_redirects, fail_on_status_code.
|
||||
|
||||
Returns:
|
||||
An APIResponse object.
|
||||
"""
|
||||
return self._fetch(url, method, **kwargs)
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Close the underlying connection pool."""
|
||||
self._pool.clear()
|
||||
|
||||
def _resolve_url(self, url: str) -> str:
|
||||
"""Resolve a URL, prepending base_url for relative paths."""
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return self._base_url.rstrip("/") + "/" + url.lstrip("/")
|
||||
return url
|
||||
|
||||
def _build_headers(self, kwargs: dict[str, Any]) -> dict[str, str]:
|
||||
"""Merge extra_headers with per-request headers."""
|
||||
headers = dict(self._extra_headers)
|
||||
if kwargs.get("headers"):
|
||||
headers.update(kwargs["headers"])
|
||||
return headers
|
||||
|
||||
def _prepare_body(self, headers: dict[str, str], kwargs: dict[str, Any]) -> bytes | None:
|
||||
"""Prepare the request body from json_data, form, or data kwargs.
|
||||
|
||||
Priority: json_data > form > data. Only one should be provided.
|
||||
"""
|
||||
json_data = kwargs.get("json_data")
|
||||
form = kwargs.get("form")
|
||||
data = kwargs.get("data")
|
||||
|
||||
if json_data is not None:
|
||||
headers.setdefault("Content-Type", "application/json")
|
||||
return json.dumps(json_data).encode("utf-8")
|
||||
elif form is not None:
|
||||
headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
|
||||
return urllib.parse.urlencode(form).encode("utf-8")
|
||||
elif data is not None:
|
||||
if isinstance(data, dict):
|
||||
headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
|
||||
return urllib.parse.urlencode(data).encode("utf-8")
|
||||
elif isinstance(data, str):
|
||||
return data.encode("utf-8")
|
||||
elif isinstance(data, bytes):
|
||||
return data
|
||||
return None
|
||||
|
||||
def _append_params(self, url: str, kwargs: dict[str, Any]) -> str:
|
||||
"""Append query parameters to the URL."""
|
||||
params = kwargs.get("params")
|
||||
if params:
|
||||
separator = "&" if "?" in url else "?"
|
||||
return url + separator + urllib.parse.urlencode(params)
|
||||
return url
|
||||
|
||||
def _execute_request(
|
||||
self, method: str, url: str, headers: dict[str, str], body: bytes | None, kwargs: dict[str, Any]
|
||||
) -> urllib3.BaseHTTPResponse:
|
||||
"""Execute the HTTP request via urllib3."""
|
||||
timeout = kwargs.get("timeout", self._timeout)
|
||||
max_redirects = kwargs.get("max_redirects", self._max_redirects)
|
||||
|
||||
follow = max_redirects > 0
|
||||
retries = Retry(
|
||||
connect=0,
|
||||
read=0,
|
||||
status=0,
|
||||
other=0,
|
||||
redirect=max_redirects if follow else 0,
|
||||
raise_on_redirect=False,
|
||||
)
|
||||
|
||||
return self._pool.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
body=body,
|
||||
timeout=timeout,
|
||||
redirect=follow,
|
||||
retries=retries,
|
||||
preload_content=True,
|
||||
)
|
||||
|
||||
def _build_response(self, resp: urllib3.BaseHTTPResponse, url: str) -> APIResponse:
|
||||
"""Build an APIResponse from a urllib3 response."""
|
||||
# Merge duplicate headers per RFC 7230 §3.2.2 (combine with ", ")
|
||||
resp_headers: dict[str, str] = {}
|
||||
for k, v in resp.headers.items():
|
||||
key = k.lower()
|
||||
if key in resp_headers:
|
||||
resp_headers[key] = resp_headers[key] + ", " + v
|
||||
else:
|
||||
resp_headers[key] = v
|
||||
# urllib3 2.x removed resp.reason; fall back to stdlib phrase lookup
|
||||
reason = getattr(resp, "reason", None)
|
||||
status_text = reason or http_status_phrases.get(resp.status, "")
|
||||
return APIResponse(
|
||||
status=resp.status,
|
||||
status_text=status_text,
|
||||
headers=resp_headers,
|
||||
url=url,
|
||||
body=resp.data,
|
||||
)
|
||||
|
||||
def _get_cookies_for_request(self, url: str) -> list[dict]:
|
||||
"""Get cookies that should be sent with the request. Overridden by subclasses."""
|
||||
return []
|
||||
|
||||
def _handle_response_cookies(self, set_cookie_headers: list[str], url: str) -> None:
|
||||
"""Process Set-Cookie headers from the response. Overridden by subclasses."""
|
||||
|
||||
def _fetch(self, url: str, method: str, **kwargs: Any) -> APIResponse:
|
||||
"""Execute an HTTP request with cookie handling.
|
||||
|
||||
Args:
|
||||
url: The request URL.
|
||||
method: The HTTP method.
|
||||
**kwargs: Optional arguments.
|
||||
|
||||
Returns:
|
||||
An APIResponse object.
|
||||
"""
|
||||
url = self._resolve_url(url)
|
||||
headers = self._build_headers(kwargs)
|
||||
|
||||
# Apply cookies
|
||||
matching_cookies = self._get_cookies_for_request(url)
|
||||
if matching_cookies:
|
||||
cookie_header = "; ".join(f"{c['name']}={c['value']}" for c in matching_cookies)
|
||||
if "Cookie" in headers:
|
||||
headers["Cookie"] = headers["Cookie"] + "; " + cookie_header
|
||||
else:
|
||||
headers["Cookie"] = cookie_header
|
||||
|
||||
body = self._prepare_body(headers, kwargs)
|
||||
url = self._append_params(url, kwargs)
|
||||
resp = self._execute_request(method, url, headers, body, kwargs)
|
||||
|
||||
# After redirects, associate cookies with the final destination's
|
||||
# origin, not the initial request URL.
|
||||
final_url = _resolve_redirect_url(resp, url)
|
||||
|
||||
# Process response cookies
|
||||
set_cookie_headers = _get_set_cookie_headers(resp)
|
||||
if set_cookie_headers:
|
||||
self._handle_response_cookies(set_cookie_headers, final_url)
|
||||
|
||||
response = self._build_response(resp, final_url)
|
||||
|
||||
fail = kwargs.get("fail_on_status_code", self._fail_on_status_code)
|
||||
if fail and not response.ok:
|
||||
raise APIRequestFailure(response)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class APIRequestContext(_BaseRequestContext):
|
||||
"""Makes HTTP requests with automatic browser cookie synchronization.
|
||||
|
||||
Cookies from the browser session are sent with API requests, and cookies
|
||||
from API responses are synced back to the browser.
|
||||
|
||||
Args:
|
||||
driver: The WebDriver instance to sync cookies with.
|
||||
base_url: Optional base URL prepended to relative request paths.
|
||||
extra_headers: Optional headers included in every request.
|
||||
timeout: Default request timeout in seconds.
|
||||
max_redirects: Maximum number of redirects to follow.
|
||||
fail_on_status_code: If True, raise APIRequestFailure for non-2xx responses.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver: "WebDriver",
|
||||
base_url: str = "",
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
timeout: float = 30.0,
|
||||
max_redirects: int = 10,
|
||||
fail_on_status_code: bool = False,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
base_url=base_url,
|
||||
extra_headers=extra_headers,
|
||||
timeout=timeout,
|
||||
max_redirects=max_redirects,
|
||||
fail_on_status_code=fail_on_status_code,
|
||||
)
|
||||
self._driver = driver
|
||||
|
||||
def new_context(
|
||||
self,
|
||||
base_url: str = "",
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
storage_state: dict | str | pathlib.Path | None = None,
|
||||
fail_on_status_code: bool = False,
|
||||
) -> "_IsolatedAPIRequestContext":
|
||||
"""Create an isolated API request context that does not sync with the browser.
|
||||
|
||||
Args:
|
||||
base_url: Optional base URL for this context.
|
||||
extra_headers: Optional headers for this context.
|
||||
storage_state: Optional cookies to pre-load, as a dict, JSON file path, or Path.
|
||||
fail_on_status_code: If True, raise APIRequestFailure for non-2xx responses.
|
||||
|
||||
Returns:
|
||||
An _IsolatedAPIRequestContext instance.
|
||||
"""
|
||||
cookies: list[dict] = []
|
||||
if storage_state is not None:
|
||||
if isinstance(storage_state, (str, pathlib.Path)):
|
||||
file_path = pathlib.Path(storage_state)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"Storage state file not found: {file_path}")
|
||||
try:
|
||||
with open(file_path) as f:
|
||||
state = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON in storage state file {file_path}: {e}") from e
|
||||
except OSError as e:
|
||||
raise OSError(f"Cannot read storage state file {file_path}: {e}") from e
|
||||
else:
|
||||
state = storage_state
|
||||
cookies = list(state.get("cookies", []))
|
||||
|
||||
return _IsolatedAPIRequestContext(
|
||||
base_url=base_url,
|
||||
extra_headers=extra_headers,
|
||||
cookies=cookies,
|
||||
timeout=self._timeout,
|
||||
max_redirects=self._max_redirects,
|
||||
fail_on_status_code=fail_on_status_code,
|
||||
)
|
||||
|
||||
def get_storage_state(self, path: str | pathlib.Path | None = None) -> dict[str, Any]:
|
||||
"""Export the current browser cookies as a storage state dict.
|
||||
|
||||
Args:
|
||||
path: Optional file path to save the storage state as JSON.
|
||||
|
||||
Returns:
|
||||
A dict with a "cookies" key containing the browser cookies.
|
||||
"""
|
||||
cookies = self._driver.get_cookies()
|
||||
state: dict[str, Any] = {"cookies": cookies}
|
||||
if path is not None:
|
||||
file_path = pathlib.Path(path)
|
||||
try:
|
||||
with open(file_path, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
except OSError as e:
|
||||
raise OSError(f"Cannot write storage state to {file_path}: {e}") from e
|
||||
return state
|
||||
|
||||
def _get_cookies_for_request(self, url: str) -> list[dict]:
|
||||
"""Get matching browser cookies for the request URL."""
|
||||
try:
|
||||
browser_cookies = self._driver.get_cookies()
|
||||
except Exception:
|
||||
logger.debug("Could not retrieve browser cookies", exc_info=True)
|
||||
return []
|
||||
# Derive default domain from the browser's current page for host-only cookies
|
||||
default_domain = ""
|
||||
try:
|
||||
current = self._driver.current_url
|
||||
if current:
|
||||
default_domain = urllib.parse.urlparse(current).hostname or ""
|
||||
except Exception:
|
||||
logger.debug("Could not get current URL for host-only cookie matching", exc_info=True)
|
||||
return [c for c in browser_cookies if _cookie_matches(c, url, default_domain)]
|
||||
|
||||
def _handle_response_cookies(self, set_cookie_headers: list[str], url: str) -> None:
|
||||
"""Sync Set-Cookie headers back to the browser."""
|
||||
parsed_url = urllib.parse.urlparse(url)
|
||||
for sc_header in set_cookie_headers:
|
||||
cookie = _parse_set_cookie(sc_header)
|
||||
if not cookie.get("name"):
|
||||
continue
|
||||
cookie.setdefault("domain", parsed_url.hostname or "")
|
||||
cookie.setdefault("path", "/")
|
||||
expiry = cookie.get("expiry")
|
||||
if expiry is not None and expiry <= int(time.time()):
|
||||
try:
|
||||
self._driver.delete_cookie(cookie["name"])
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
try:
|
||||
self._driver.add_cookie(cookie)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Could not sync cookie '%s' to browser (domain mismatch with current page)",
|
||||
cookie.get("name"),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
class _IsolatedAPIRequestContext(_BaseRequestContext):
|
||||
"""An isolated API request context that maintains its own cookie jar.
|
||||
|
||||
Does not synchronize cookies with any browser session.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "",
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
cookies: list[dict] | None = None,
|
||||
timeout: float = 30.0,
|
||||
max_redirects: int = 10,
|
||||
fail_on_status_code: bool = False,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
base_url=base_url,
|
||||
extra_headers=extra_headers,
|
||||
timeout=timeout,
|
||||
max_redirects=max_redirects,
|
||||
fail_on_status_code=fail_on_status_code,
|
||||
)
|
||||
self._cookies: list[dict] = cookies or []
|
||||
|
||||
def get_storage_state(self) -> dict[str, Any]:
|
||||
"""Return the current cookies as a storage state dict."""
|
||||
return {"cookies": list(self._cookies)}
|
||||
|
||||
def _get_cookies_for_request(self, url: str) -> list[dict]:
|
||||
"""Get matching cookies from the internal jar."""
|
||||
# For isolated contexts, use the request hostname as default domain
|
||||
default_domain = urllib.parse.urlparse(url).hostname or ""
|
||||
return [c for c in self._cookies if _cookie_matches(c, url, default_domain)]
|
||||
|
||||
def _handle_response_cookies(self, set_cookie_headers: list[str], url: str) -> None:
|
||||
"""Store Set-Cookie headers in the internal jar."""
|
||||
parsed_url = urllib.parse.urlparse(url)
|
||||
now = int(time.time())
|
||||
for sc_header in set_cookie_headers:
|
||||
cookie = _parse_set_cookie(sc_header)
|
||||
if not cookie.get("name"):
|
||||
continue
|
||||
cookie.setdefault("domain", parsed_url.hostname or "")
|
||||
cookie.setdefault("path", "/")
|
||||
# Cookies are unique by (name, domain, path)
|
||||
key = (cookie["name"], cookie.get("domain", ""), cookie.get("path", "/"))
|
||||
# Remove existing cookie with same key
|
||||
self._cookies = [
|
||||
c for c in self._cookies if (c.get("name"), c.get("domain", ""), c.get("path", "/")) != key
|
||||
]
|
||||
# Only store if not expired (Max-Age=0 or negative means delete)
|
||||
expiry = cookie.get("expiry")
|
||||
if expiry is not None and expiry <= now:
|
||||
continue
|
||||
self._cookies.append(cookie)
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+280
@@ -0,0 +1,280 @@
|
||||
# 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.
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from selenium.webdriver.common.bidi.common import command_builder
|
||||
from selenium.webdriver.common.bidi.session import UserPromptHandler
|
||||
from selenium.webdriver.common.proxy import Proxy
|
||||
|
||||
|
||||
class ClientWindowState:
|
||||
"""Represents a window state."""
|
||||
|
||||
FULLSCREEN = "fullscreen"
|
||||
MAXIMIZED = "maximized"
|
||||
MINIMIZED = "minimized"
|
||||
NORMAL = "normal"
|
||||
|
||||
VALID_STATES = {FULLSCREEN, MAXIMIZED, MINIMIZED, NORMAL}
|
||||
|
||||
|
||||
class ClientWindowInfo:
|
||||
"""Represents a client window information."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_window: str,
|
||||
state: str,
|
||||
width: int,
|
||||
height: int,
|
||||
x: int,
|
||||
y: int,
|
||||
active: bool,
|
||||
):
|
||||
self.client_window = client_window
|
||||
self.state = state
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.active = active
|
||||
|
||||
def get_state(self) -> str:
|
||||
"""Gets the state of the client window.
|
||||
|
||||
Returns:
|
||||
str: The state of the client window (one of the ClientWindowState constants).
|
||||
"""
|
||||
return self.state
|
||||
|
||||
def get_client_window(self) -> str:
|
||||
"""Gets the client window identifier.
|
||||
|
||||
Returns:
|
||||
str: The client window identifier.
|
||||
"""
|
||||
return self.client_window
|
||||
|
||||
def get_width(self) -> int:
|
||||
"""Gets the width of the client window.
|
||||
|
||||
Returns:
|
||||
int: The width of the client window.
|
||||
"""
|
||||
return self.width
|
||||
|
||||
def get_height(self) -> int:
|
||||
"""Gets the height of the client window.
|
||||
|
||||
Returns:
|
||||
int: The height of the client window.
|
||||
"""
|
||||
return self.height
|
||||
|
||||
def get_x(self) -> int:
|
||||
"""Gets the x coordinate of the client window.
|
||||
|
||||
Returns:
|
||||
int: The x coordinate of the client window.
|
||||
"""
|
||||
return self.x
|
||||
|
||||
def get_y(self) -> int:
|
||||
"""Gets the y coordinate of the client window.
|
||||
|
||||
Returns:
|
||||
int: The y coordinate of the client window.
|
||||
"""
|
||||
return self.y
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""Checks if the client window is active.
|
||||
|
||||
Returns:
|
||||
bool: True if the client window is active, False otherwise.
|
||||
"""
|
||||
return self.active
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ClientWindowInfo":
|
||||
"""Creates a ClientWindowInfo instance from a dictionary.
|
||||
|
||||
Args:
|
||||
data: A dictionary containing the client window information.
|
||||
|
||||
Returns:
|
||||
ClientWindowInfo: A new instance of ClientWindowInfo.
|
||||
|
||||
Raises:
|
||||
ValueError: If required fields are missing or have invalid types.
|
||||
"""
|
||||
try:
|
||||
client_window = data["clientWindow"]
|
||||
if not isinstance(client_window, str):
|
||||
raise ValueError("clientWindow must be a string")
|
||||
|
||||
state = data["state"]
|
||||
if not isinstance(state, str):
|
||||
raise ValueError("state must be a string")
|
||||
if state not in ClientWindowState.VALID_STATES:
|
||||
raise ValueError(f"Invalid state: {state}. Must be one of {ClientWindowState.VALID_STATES}")
|
||||
|
||||
width = data["width"]
|
||||
if not isinstance(width, int) or width < 0:
|
||||
raise ValueError(f"width must be a non-negative integer, got {width}")
|
||||
|
||||
height = data["height"]
|
||||
if not isinstance(height, int) or height < 0:
|
||||
raise ValueError(f"height must be a non-negative integer, got {height}")
|
||||
|
||||
x = data["x"]
|
||||
if not isinstance(x, int):
|
||||
raise ValueError(f"x must be an integer, got {type(x).__name__}")
|
||||
|
||||
y = data["y"]
|
||||
if not isinstance(y, int):
|
||||
raise ValueError(f"y must be an integer, got {type(y).__name__}")
|
||||
|
||||
active = data["active"]
|
||||
if not isinstance(active, bool):
|
||||
raise ValueError("active must be a boolean")
|
||||
|
||||
return cls(
|
||||
client_window=client_window,
|
||||
state=state,
|
||||
width=width,
|
||||
height=height,
|
||||
x=x,
|
||||
y=y,
|
||||
active=active,
|
||||
)
|
||||
except (KeyError, TypeError) as e:
|
||||
raise ValueError(f"Invalid data format for ClientWindowInfo: {e}") from e
|
||||
|
||||
|
||||
class Browser:
|
||||
"""BiDi implementation of the browser module."""
|
||||
|
||||
def __init__(self, conn):
|
||||
self.conn = conn
|
||||
|
||||
def create_user_context(
|
||||
self,
|
||||
accept_insecure_certs: bool | None = None,
|
||||
proxy: Proxy | None = None,
|
||||
unhandled_prompt_behavior: UserPromptHandler | None = None,
|
||||
) -> str:
|
||||
"""Creates a new user context.
|
||||
|
||||
Args:
|
||||
accept_insecure_certs: Optional flag to accept insecure TLS certificates.
|
||||
proxy: Optional proxy configuration for the user context.
|
||||
unhandled_prompt_behavior: Optional configuration for handling user prompts.
|
||||
|
||||
Returns:
|
||||
str: The ID of the created user context.
|
||||
"""
|
||||
params: dict[str, Any] = {}
|
||||
|
||||
if accept_insecure_certs is not None:
|
||||
params["acceptInsecureCerts"] = accept_insecure_certs
|
||||
|
||||
if proxy is not None:
|
||||
params["proxy"] = proxy.to_bidi_dict()
|
||||
|
||||
if unhandled_prompt_behavior is not None:
|
||||
params["unhandledPromptBehavior"] = unhandled_prompt_behavior.to_dict()
|
||||
|
||||
result = self.conn.execute(command_builder("browser.createUserContext", params))
|
||||
return result["userContext"]
|
||||
|
||||
def get_user_contexts(self) -> list[str]:
|
||||
"""Gets all user contexts.
|
||||
|
||||
Returns:
|
||||
List[str]: A list of user context IDs.
|
||||
"""
|
||||
result = self.conn.execute(command_builder("browser.getUserContexts", {}))
|
||||
return [context_info["userContext"] for context_info in result["userContexts"]]
|
||||
|
||||
def remove_user_context(self, user_context_id: str) -> None:
|
||||
"""Removes a user context.
|
||||
|
||||
Args:
|
||||
user_context_id: The ID of the user context to remove.
|
||||
|
||||
Raises:
|
||||
ValueError: If the user context ID is "default" or does not exist.
|
||||
"""
|
||||
if user_context_id == "default":
|
||||
raise ValueError("Cannot remove the default user context")
|
||||
|
||||
params = {"userContext": user_context_id}
|
||||
self.conn.execute(command_builder("browser.removeUserContext", params))
|
||||
|
||||
def get_client_windows(self) -> list[ClientWindowInfo]:
|
||||
"""Gets all client windows.
|
||||
|
||||
Returns:
|
||||
List[ClientWindowInfo]: A list of client window information.
|
||||
"""
|
||||
result = self.conn.execute(command_builder("browser.getClientWindows", {}))
|
||||
return [ClientWindowInfo.from_dict(window) for window in result["clientWindows"]]
|
||||
|
||||
def set_download_behavior(
|
||||
self,
|
||||
*,
|
||||
allowed: bool | None = None,
|
||||
destination_folder: str | os.PathLike | None = None,
|
||||
user_contexts: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Set the download behavior for the browser or specific user contexts.
|
||||
|
||||
Args:
|
||||
allowed: True to allow downloads, False to deny downloads, or None to
|
||||
clear download behavior (revert to default).
|
||||
destination_folder: Required when allowed is True. Specifies the folder
|
||||
to store downloads in.
|
||||
user_contexts: Optional list of user context IDs to apply this
|
||||
behavior to. If omitted, updates the default behavior.
|
||||
|
||||
Raises:
|
||||
ValueError: If allowed=True and destination_folder is missing, or if
|
||||
allowed=False and destination_folder is provided.
|
||||
"""
|
||||
params: dict[str, Any] = {}
|
||||
|
||||
if allowed is None:
|
||||
params["downloadBehavior"] = None
|
||||
else:
|
||||
if allowed:
|
||||
if not destination_folder:
|
||||
raise ValueError("destination_folder is required when allowed=True.")
|
||||
params["downloadBehavior"] = {
|
||||
"type": "allowed",
|
||||
"destinationFolder": os.fspath(destination_folder),
|
||||
}
|
||||
else:
|
||||
if destination_folder:
|
||||
raise ValueError("destination_folder should not be provided when allowed=False.")
|
||||
params["downloadBehavior"] = {"type": "denied"}
|
||||
|
||||
if user_contexts is not None:
|
||||
params["userContexts"] = user_contexts
|
||||
|
||||
self.conn.execute(command_builder("browser.setDownloadBehavior", params))
|
||||
+1060
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,515 @@
|
||||
# The MIT License(MIT)
|
||||
#
|
||||
# Copyright(c) 2018 Hyperion Gray
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files(the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
#
|
||||
# This code comes from https://github.com/HyperionGray/trio-chrome-devtools-protocol/tree/master/trio_cdp
|
||||
|
||||
import contextvars
|
||||
import importlib
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import pathlib
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncGenerator, AsyncIterator, Generator
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, TypeVar
|
||||
|
||||
import trio
|
||||
from trio_websocket import ConnectionClosed as WsConnectionClosed
|
||||
from trio_websocket import connect_websocket_url
|
||||
|
||||
logger = logging.getLogger("trio_cdp")
|
||||
T = TypeVar("T")
|
||||
MAX_WS_MESSAGE_SIZE = 2**24
|
||||
|
||||
devtools = None
|
||||
version = None
|
||||
|
||||
|
||||
def import_devtools(ver):
|
||||
"""Attempt to load the current latest available devtools into the module cache for use later."""
|
||||
global devtools
|
||||
global version
|
||||
version = ver
|
||||
base = "selenium.webdriver.common.devtools.v"
|
||||
try:
|
||||
devtools = importlib.import_module(f"{base}{ver}")
|
||||
return devtools
|
||||
except ModuleNotFoundError:
|
||||
# Attempt to parse and load the 'most recent' devtools module. This is likely
|
||||
# because cdp has been updated but selenium python has not been released yet.
|
||||
devtools_path = pathlib.Path(__file__).parents[1].joinpath("devtools")
|
||||
versions = tuple(f.name for f in devtools_path.iterdir() if f.is_dir() and f.name != "latest")
|
||||
latest = max(int(x[1:]) for x in versions)
|
||||
selenium_logger = logging.getLogger(__name__)
|
||||
selenium_logger.debug("Falling back to loading `devtools`: v%s", latest)
|
||||
devtools = importlib.import_module(f"{base}{latest}")
|
||||
return devtools
|
||||
|
||||
|
||||
_connection_context: contextvars.ContextVar = contextvars.ContextVar("connection_context")
|
||||
_session_context: contextvars.ContextVar = contextvars.ContextVar("session_context")
|
||||
|
||||
|
||||
def get_connection_context(fn_name):
|
||||
"""Look up the current connection.
|
||||
|
||||
If there is no current connection, raise a ``RuntimeError`` with a
|
||||
helpful message.
|
||||
"""
|
||||
try:
|
||||
return _connection_context.get()
|
||||
except LookupError:
|
||||
raise RuntimeError(f"{fn_name}() must be called in a connection context.")
|
||||
|
||||
|
||||
def get_session_context(fn_name):
|
||||
"""Look up the current session.
|
||||
|
||||
If there is no current session, raise a ``RuntimeError`` with a
|
||||
helpful message.
|
||||
"""
|
||||
try:
|
||||
return _session_context.get()
|
||||
except LookupError:
|
||||
raise RuntimeError(f"{fn_name}() must be called in a session context.")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def connection_context(connection):
|
||||
"""Context manager installs ``connection`` as the session context for the current Trio task."""
|
||||
token = _connection_context.set(connection)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_connection_context.reset(token)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session_context(session):
|
||||
"""Context manager installs ``session`` as the session context for the current Trio task."""
|
||||
token = _session_context.set(session)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_session_context.reset(token)
|
||||
|
||||
|
||||
def set_global_connection(connection):
|
||||
"""Install ``connection`` in the root context so that it will become the default connection for all tasks.
|
||||
|
||||
This is generally not recommended, except it may be necessary in
|
||||
certain use cases such as running inside Jupyter notebook.
|
||||
"""
|
||||
global _connection_context
|
||||
_connection_context = contextvars.ContextVar("_connection_context", default=connection)
|
||||
|
||||
|
||||
def set_global_session(session):
|
||||
"""Install ``session`` in the root context so that it will become the default session for all tasks.
|
||||
|
||||
This is generally not recommended, except it may be necessary in
|
||||
certain use cases such as running inside Jupyter notebook.
|
||||
"""
|
||||
global _session_context
|
||||
_session_context = contextvars.ContextVar("_session_context", default=session)
|
||||
|
||||
|
||||
class BrowserError(Exception):
|
||||
"""This exception is raised when the browser's response to a command indicates that an error occurred."""
|
||||
|
||||
def __init__(self, obj):
|
||||
self.code = obj.get("code")
|
||||
self.message = obj.get("message")
|
||||
self.detail = obj.get("data")
|
||||
|
||||
def __str__(self):
|
||||
return f"BrowserError<code={self.code} message={self.message}> {self.detail}"
|
||||
|
||||
|
||||
class CdpConnectionClosed(WsConnectionClosed):
|
||||
"""Raised when a public method is called on a closed CDP connection."""
|
||||
|
||||
def __init__(self, reason):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
reason: wsproto.frame_protocol.CloseReason
|
||||
"""
|
||||
self.reason = reason
|
||||
|
||||
def __repr__(self):
|
||||
"""Return representation."""
|
||||
return f"{self.__class__.__name__}<{self.reason}>"
|
||||
|
||||
|
||||
class InternalError(Exception):
|
||||
"""This exception is only raised when there is faulty logic in TrioCDP or the integration with PyCDP."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class CmEventProxy:
|
||||
"""A proxy object returned by :meth:`CdpBase.wait_for()``.
|
||||
|
||||
After the context manager executes, this proxy object will have a
|
||||
value set that contains the returned event.
|
||||
"""
|
||||
|
||||
value: Any = None
|
||||
|
||||
|
||||
class CdpBase:
|
||||
def __init__(self, ws, session_id, target_id):
|
||||
self.ws = ws
|
||||
self.session_id = session_id
|
||||
self.target_id = target_id
|
||||
self.channels = defaultdict(set)
|
||||
self.id_iter = itertools.count()
|
||||
self.inflight_cmd = {}
|
||||
self.inflight_result = {}
|
||||
|
||||
async def execute(self, cmd: Generator[dict, T, Any]) -> T:
|
||||
"""Execute a command on the server and wait for the result.
|
||||
|
||||
Args:
|
||||
cmd: any CDP command
|
||||
|
||||
Returns:
|
||||
a CDP result
|
||||
"""
|
||||
cmd_id = next(self.id_iter)
|
||||
cmd_event = trio.Event()
|
||||
self.inflight_cmd[cmd_id] = cmd, cmd_event
|
||||
request = next(cmd)
|
||||
request["id"] = cmd_id
|
||||
if self.session_id:
|
||||
request["sessionId"] = self.session_id
|
||||
request_str = json.dumps(request)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(f"Sending CDP message: {cmd_id} {cmd_event}: {request_str}")
|
||||
try:
|
||||
await self.ws.send_message(request_str)
|
||||
except WsConnectionClosed as wcc:
|
||||
raise CdpConnectionClosed(wcc.reason) from None
|
||||
await cmd_event.wait()
|
||||
response = self.inflight_result.pop(cmd_id)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(f"Received CDP message: {response}")
|
||||
if isinstance(response, Exception):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(f"Exception raised by {cmd_event} message: {type(response).__name__}")
|
||||
raise response
|
||||
return response
|
||||
|
||||
def listen(self, *event_types, buffer_size=10):
|
||||
"""Listen for events.
|
||||
|
||||
Returns:
|
||||
An async iterator that iterates over events matching the indicated types.
|
||||
"""
|
||||
sender, receiver = trio.open_memory_channel(buffer_size)
|
||||
for event_type in event_types:
|
||||
self.channels[event_type].add(sender)
|
||||
return receiver
|
||||
|
||||
@asynccontextmanager
|
||||
async def wait_for(self, event_type: type[T], buffer_size=10) -> AsyncGenerator[CmEventProxy, None]:
|
||||
"""Wait for an event of the given type and return it.
|
||||
|
||||
This is an async context manager, so you should open it inside
|
||||
an async with block. The block will not exit until the indicated
|
||||
event is received.
|
||||
"""
|
||||
sender: trio.MemorySendChannel
|
||||
receiver: trio.MemoryReceiveChannel
|
||||
sender, receiver = trio.open_memory_channel(buffer_size)
|
||||
self.channels[event_type].add(sender)
|
||||
proxy = CmEventProxy()
|
||||
yield proxy
|
||||
async with receiver:
|
||||
event = await receiver.receive()
|
||||
proxy.value = event
|
||||
|
||||
def _handle_data(self, data):
|
||||
"""Handle incoming WebSocket data.
|
||||
|
||||
Args:
|
||||
data: a JSON dictionary
|
||||
"""
|
||||
if "id" in data:
|
||||
self._handle_cmd_response(data)
|
||||
else:
|
||||
self._handle_event(data)
|
||||
|
||||
def _handle_cmd_response(self, data: dict):
|
||||
"""Handle a response to a command.
|
||||
|
||||
This will set an event flag that will return control to the
|
||||
task that called the command.
|
||||
|
||||
Args:
|
||||
data: response as a JSON dictionary
|
||||
"""
|
||||
cmd_id = data["id"]
|
||||
try:
|
||||
cmd, event = self.inflight_cmd.pop(cmd_id)
|
||||
except KeyError:
|
||||
logger.warning("Got a message with a command ID that does not exist: %s", data)
|
||||
return
|
||||
if "error" in data:
|
||||
# If the server reported an error, convert it to an exception and do
|
||||
# not process the response any further.
|
||||
self.inflight_result[cmd_id] = BrowserError(data["error"])
|
||||
else:
|
||||
# Otherwise, continue the generator to parse the JSON result
|
||||
# into a CDP object.
|
||||
try:
|
||||
_ = cmd.send(data["result"])
|
||||
raise InternalError("The command's generator function did not exit when expected!")
|
||||
except StopIteration as exit:
|
||||
return_ = exit.value
|
||||
self.inflight_result[cmd_id] = return_
|
||||
event.set()
|
||||
|
||||
def _handle_event(self, data: dict):
|
||||
"""Handle an event.
|
||||
|
||||
Args:
|
||||
data: event as a JSON dictionary
|
||||
"""
|
||||
global devtools
|
||||
if devtools is None:
|
||||
raise RuntimeError("CDP devtools module not loaded. Call import_devtools() first.")
|
||||
event = devtools.util.parse_json_event(data)
|
||||
logger.debug("Received event: %s", event)
|
||||
to_remove = set()
|
||||
for sender in self.channels[type(event)]:
|
||||
try:
|
||||
sender.send_nowait(event)
|
||||
except trio.WouldBlock:
|
||||
logger.error('Unable to send event "%r" due to full channel %s', event, sender)
|
||||
except trio.BrokenResourceError:
|
||||
to_remove.add(sender)
|
||||
if to_remove:
|
||||
self.channels[type(event)] -= to_remove
|
||||
|
||||
|
||||
class CdpSession(CdpBase):
|
||||
"""Contains the state for a CDP session.
|
||||
|
||||
Generally you should not instantiate this object yourself; you should call
|
||||
:meth:`CdpConnection.open_session`.
|
||||
"""
|
||||
|
||||
def __init__(self, ws, session_id, target_id):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
ws: trio_websocket.WebSocketConnection
|
||||
session_id: devtools.target.SessionID
|
||||
target_id: devtools.target.TargetID
|
||||
"""
|
||||
super().__init__(ws, session_id, target_id)
|
||||
|
||||
self._dom_enable_count = 0
|
||||
self._dom_enable_lock = trio.Lock()
|
||||
self._page_enable_count = 0
|
||||
self._page_enable_lock = trio.Lock()
|
||||
|
||||
@asynccontextmanager
|
||||
async def dom_enable(self):
|
||||
"""Context manager that executes ``dom.enable()`` when it enters and then calls ``dom.disable()``.
|
||||
|
||||
This keeps track of concurrent callers and only disables DOM
|
||||
events when all callers have exited.
|
||||
"""
|
||||
global devtools
|
||||
async with self._dom_enable_lock:
|
||||
self._dom_enable_count += 1
|
||||
if self._dom_enable_count == 1:
|
||||
await self.execute(devtools.dom.enable())
|
||||
|
||||
yield
|
||||
|
||||
async with self._dom_enable_lock:
|
||||
self._dom_enable_count -= 1
|
||||
if self._dom_enable_count == 0:
|
||||
await self.execute(devtools.dom.disable())
|
||||
|
||||
@asynccontextmanager
|
||||
async def page_enable(self):
|
||||
"""Context manager executes ``page.enable()`` when it enters and then calls ``page.disable()`` when it exits.
|
||||
|
||||
This keeps track of concurrent callers and only disables page
|
||||
events when all callers have exited.
|
||||
"""
|
||||
global devtools
|
||||
async with self._page_enable_lock:
|
||||
self._page_enable_count += 1
|
||||
if self._page_enable_count == 1:
|
||||
await self.execute(devtools.page.enable())
|
||||
|
||||
yield
|
||||
|
||||
async with self._page_enable_lock:
|
||||
self._page_enable_count -= 1
|
||||
if self._page_enable_count == 0:
|
||||
await self.execute(devtools.page.disable())
|
||||
|
||||
|
||||
class CdpConnection(CdpBase, trio.abc.AsyncResource):
|
||||
"""Contains the connection state for a Chrome DevTools Protocol server.
|
||||
|
||||
CDP can multiplex multiple "sessions" over a single connection. This
|
||||
class corresponds to the "root" session, i.e. the implicitly created
|
||||
session that has no session ID. This class is responsible for
|
||||
reading incoming WebSocket messages and forwarding them to the
|
||||
corresponding session, as well as handling messages targeted at the
|
||||
root session itself. You should generally call the
|
||||
:func:`open_cdp()` instead of instantiating this class directly.
|
||||
"""
|
||||
|
||||
def __init__(self, ws):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
ws: trio_websocket.WebSocketConnection
|
||||
"""
|
||||
super().__init__(ws, session_id=None, target_id=None)
|
||||
self.sessions = {}
|
||||
|
||||
async def aclose(self):
|
||||
"""Close the underlying WebSocket connection.
|
||||
|
||||
This will cause the reader task to gracefully exit when it tries
|
||||
to read the next message from the WebSocket. All of the public
|
||||
APIs (``execute()``, ``listen()``, etc.) will raise
|
||||
``CdpConnectionClosed`` after the CDP connection is closed. It
|
||||
is safe to call this multiple times.
|
||||
"""
|
||||
await self.ws.aclose()
|
||||
|
||||
@asynccontextmanager
|
||||
async def open_session(self, target_id) -> AsyncIterator[CdpSession]:
|
||||
"""Context manager opens a session and enables the "simple" style of calling CDP APIs.
|
||||
|
||||
For example, inside a session context, you can call ``await
|
||||
dom.get_document()`` and it will execute on the current session
|
||||
automatically.
|
||||
"""
|
||||
session = await self.connect_session(target_id)
|
||||
with session_context(session):
|
||||
yield session
|
||||
|
||||
async def connect_session(self, target_id) -> "CdpSession":
|
||||
"""Returns a new :class:`CdpSession` connected to the specified target."""
|
||||
global devtools
|
||||
if devtools is None:
|
||||
raise RuntimeError("CDP devtools module not loaded. Call import_devtools() first.")
|
||||
session_id = await self.execute(devtools.target.attach_to_target(target_id, True))
|
||||
session = CdpSession(self.ws, session_id, target_id)
|
||||
self.sessions[session_id] = session
|
||||
return session
|
||||
|
||||
async def _reader_task(self):
|
||||
"""Runs in the background and handles incoming messages.
|
||||
|
||||
Dispatches responses to commands and events to listeners.
|
||||
"""
|
||||
global devtools
|
||||
if devtools is None:
|
||||
raise RuntimeError("CDP devtools module not loaded. Call import_devtools() first.")
|
||||
while True:
|
||||
try:
|
||||
message = await self.ws.get_message()
|
||||
except WsConnectionClosed:
|
||||
# If the WebSocket is closed, we don't want to throw an
|
||||
# exception from the reader task. Instead we will throw
|
||||
# exceptions from the public API methods, and we can quietly
|
||||
# exit the reader task here.
|
||||
break
|
||||
try:
|
||||
data = json.loads(message)
|
||||
except json.JSONDecodeError:
|
||||
raise BrowserError({"code": -32700, "message": "Client received invalid JSON", "data": message})
|
||||
logger.debug("Received message %r", data)
|
||||
if "sessionId" in data:
|
||||
session_id = devtools.target.SessionID(data["sessionId"])
|
||||
try:
|
||||
session = self.sessions[session_id]
|
||||
except KeyError:
|
||||
raise BrowserError(
|
||||
{
|
||||
"code": -32700,
|
||||
"message": "Browser sent a message for an invalid session",
|
||||
"data": f"{session_id!r}",
|
||||
}
|
||||
)
|
||||
session._handle_data(data)
|
||||
else:
|
||||
self._handle_data(data)
|
||||
|
||||
for _, session in self.sessions.items():
|
||||
for _, senders in session.channels.items():
|
||||
for sender in senders:
|
||||
sender.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def open_cdp(url) -> AsyncIterator[CdpConnection]:
|
||||
"""Async context manager opens a connection to the browser then closes the connection when the block exits.
|
||||
|
||||
The context manager also sets the connection as the default
|
||||
connection for the current task, so that commands like ``await
|
||||
target.get_targets()`` will run on this connection automatically. If
|
||||
you want to use multiple connections concurrently, it is recommended
|
||||
to open each on in a separate task.
|
||||
"""
|
||||
async with trio.open_nursery() as nursery:
|
||||
conn = await connect_cdp(nursery, url)
|
||||
try:
|
||||
with connection_context(conn):
|
||||
yield conn
|
||||
finally:
|
||||
await conn.aclose()
|
||||
|
||||
|
||||
async def connect_cdp(nursery, url) -> CdpConnection:
|
||||
"""Connect to the browser specified by ``url`` and spawn a background task in the specified nursery.
|
||||
|
||||
The ``open_cdp()`` context manager is preferred in most situations.
|
||||
You should only use this function if you need to specify a custom
|
||||
nursery. This connection is not automatically closed! You can either
|
||||
use the connection object as a context manager (``async with
|
||||
conn:``) or else call ``await conn.aclose()`` on it when you are
|
||||
done with it. If ``set_context`` is True, then the returned
|
||||
connection will be installed as the default connection for the
|
||||
current task. This argument is for unusual use cases, such as
|
||||
running inside of a notebook.
|
||||
"""
|
||||
ws = await connect_websocket_url(nursery, url, max_message_size=MAX_WS_MESSAGE_SIZE)
|
||||
cdp_conn = CdpConnection(ws)
|
||||
nursery.start_soon(cdp_conn._reader_task)
|
||||
return cdp_conn
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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.
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
|
||||
def command_builder(method: str, params: dict | None = None) -> Generator[dict, dict, dict]:
|
||||
"""Build a command iterator to send to the BiDi protocol.
|
||||
|
||||
Args:
|
||||
method: The method to execute.
|
||||
params: The parameters to pass to the method. Default is None.
|
||||
|
||||
Returns:
|
||||
The response from the command execution.
|
||||
"""
|
||||
if params is None:
|
||||
params = {}
|
||||
|
||||
command = {"method": method, "params": params}
|
||||
cmd = yield command
|
||||
return cmd
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# 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.
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Console(Enum):
|
||||
ALL = "all"
|
||||
LOG = "log"
|
||||
ERROR = "error"
|
||||
+524
@@ -0,0 +1,524 @@
|
||||
# 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.
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, TypeVar
|
||||
|
||||
from selenium.webdriver.common.bidi.common import command_builder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from selenium.webdriver.remote.websocket_connection import WebSocketConnection
|
||||
|
||||
|
||||
class ScreenOrientationNatural(Enum):
|
||||
"""Natural screen orientation."""
|
||||
|
||||
PORTRAIT = "portrait"
|
||||
LANDSCAPE = "landscape"
|
||||
|
||||
|
||||
class ScreenOrientationType(Enum):
|
||||
"""Screen orientation type."""
|
||||
|
||||
PORTRAIT_PRIMARY = "portrait-primary"
|
||||
PORTRAIT_SECONDARY = "portrait-secondary"
|
||||
LANDSCAPE_PRIMARY = "landscape-primary"
|
||||
LANDSCAPE_SECONDARY = "landscape-secondary"
|
||||
|
||||
|
||||
E = TypeVar("E", ScreenOrientationNatural, ScreenOrientationType)
|
||||
|
||||
|
||||
def _convert_to_enum(value: E | str, enum_class: type[E]) -> E:
|
||||
if isinstance(value, enum_class):
|
||||
return value
|
||||
assert isinstance(value, str)
|
||||
try:
|
||||
return enum_class(value.lower())
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid orientation: {value}")
|
||||
|
||||
|
||||
class ScreenOrientation:
|
||||
"""Represents screen orientation configuration."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
natural: ScreenOrientationNatural | str,
|
||||
type: ScreenOrientationType | str,
|
||||
):
|
||||
"""Initialize ScreenOrientation.
|
||||
|
||||
Args:
|
||||
natural: Natural screen orientation ("portrait" or "landscape").
|
||||
type: Screen orientation type ("portrait-primary", "portrait-secondary",
|
||||
"landscape-primary", or "landscape-secondary").
|
||||
|
||||
Raises:
|
||||
ValueError: If natural or type values are invalid.
|
||||
"""
|
||||
# handle string values
|
||||
self.natural = _convert_to_enum(natural, ScreenOrientationNatural)
|
||||
self.type = _convert_to_enum(type, ScreenOrientationType)
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
return {
|
||||
"natural": self.natural.value,
|
||||
"type": self.type.value,
|
||||
}
|
||||
|
||||
|
||||
class GeolocationCoordinates:
|
||||
"""Represents geolocation coordinates."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
accuracy: float = 1.0,
|
||||
altitude: float | None = None,
|
||||
altitude_accuracy: float | None = None,
|
||||
heading: float | None = None,
|
||||
speed: float | None = None,
|
||||
):
|
||||
"""Initialize GeolocationCoordinates.
|
||||
|
||||
Args:
|
||||
latitude: Latitude coordinate (-90.0 to 90.0).
|
||||
longitude: Longitude coordinate (-180.0 to 180.0).
|
||||
accuracy: Accuracy in meters (>= 0.0), defaults to 1.0.
|
||||
altitude: Altitude in meters or None, defaults to None.
|
||||
altitude_accuracy: Altitude accuracy in meters (>= 0.0) or None, defaults to None.
|
||||
heading: Heading in degrees (0.0 to 360.0) or None, defaults to None.
|
||||
speed: Speed in meters per second (>= 0.0) or None, defaults to None.
|
||||
|
||||
Raises:
|
||||
ValueError: If coordinates are out of valid range or if altitude_accuracy is provided without altitude.
|
||||
"""
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.accuracy = accuracy
|
||||
self.altitude = altitude
|
||||
self.altitude_accuracy = altitude_accuracy
|
||||
self.heading = heading
|
||||
self.speed = speed
|
||||
|
||||
@property
|
||||
def latitude(self) -> float:
|
||||
return self._latitude
|
||||
|
||||
@latitude.setter
|
||||
def latitude(self, value: float) -> None:
|
||||
if not (-90.0 <= value <= 90.0):
|
||||
raise ValueError("latitude must be between -90.0 and 90.0")
|
||||
self._latitude = value
|
||||
|
||||
@property
|
||||
def longitude(self) -> float:
|
||||
return self._longitude
|
||||
|
||||
@longitude.setter
|
||||
def longitude(self, value: float) -> None:
|
||||
if not (-180.0 <= value <= 180.0):
|
||||
raise ValueError("longitude must be between -180.0 and 180.0")
|
||||
self._longitude = value
|
||||
|
||||
@property
|
||||
def accuracy(self) -> float:
|
||||
return self._accuracy
|
||||
|
||||
@accuracy.setter
|
||||
def accuracy(self, value: float) -> None:
|
||||
if value < 0.0:
|
||||
raise ValueError("accuracy must be >= 0.0")
|
||||
self._accuracy = value
|
||||
|
||||
@property
|
||||
def altitude(self) -> float | None:
|
||||
return self._altitude
|
||||
|
||||
@altitude.setter
|
||||
def altitude(self, value: float | None) -> None:
|
||||
self._altitude = value
|
||||
|
||||
@property
|
||||
def altitude_accuracy(self) -> float | None:
|
||||
return self._altitude_accuracy
|
||||
|
||||
@altitude_accuracy.setter
|
||||
def altitude_accuracy(self, value: float | None) -> None:
|
||||
if value is not None and self.altitude is None:
|
||||
raise ValueError("altitude_accuracy cannot be set without altitude")
|
||||
if value is not None and value < 0.0:
|
||||
raise ValueError("altitude_accuracy must be >= 0.0")
|
||||
self._altitude_accuracy = value
|
||||
|
||||
@property
|
||||
def heading(self) -> float | None:
|
||||
return self._heading
|
||||
|
||||
@heading.setter
|
||||
def heading(self, value: float | None) -> None:
|
||||
if value is not None and not (0.0 <= value < 360.0):
|
||||
raise ValueError("heading must be between 0.0 and 360.0")
|
||||
self._heading = value
|
||||
|
||||
@property
|
||||
def speed(self) -> float | None:
|
||||
return self._speed
|
||||
|
||||
@speed.setter
|
||||
def speed(self, value: float | None) -> None:
|
||||
if value is not None and value < 0.0:
|
||||
raise ValueError("speed must be >= 0.0")
|
||||
self._speed = value
|
||||
|
||||
def to_dict(self) -> dict[str, float | None]:
|
||||
result: dict[str, float | None] = {
|
||||
"latitude": self.latitude,
|
||||
"longitude": self.longitude,
|
||||
"accuracy": self.accuracy,
|
||||
}
|
||||
|
||||
if self.altitude is not None:
|
||||
result["altitude"] = self.altitude
|
||||
|
||||
if self.altitude_accuracy is not None:
|
||||
result["altitudeAccuracy"] = self.altitude_accuracy
|
||||
|
||||
if self.heading is not None:
|
||||
result["heading"] = self.heading
|
||||
|
||||
if self.speed is not None:
|
||||
result["speed"] = self.speed
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class GeolocationPositionError:
|
||||
"""Represents a geolocation position error."""
|
||||
|
||||
TYPE_POSITION_UNAVAILABLE = "positionUnavailable"
|
||||
|
||||
def __init__(self, type: str = TYPE_POSITION_UNAVAILABLE):
|
||||
if type != self.TYPE_POSITION_UNAVAILABLE:
|
||||
raise ValueError(f'type must be "{self.TYPE_POSITION_UNAVAILABLE}"')
|
||||
self.type = type
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
return {"type": self.type}
|
||||
|
||||
|
||||
class Emulation:
|
||||
"""BiDi implementation of the emulation module."""
|
||||
|
||||
def __init__(self, conn: WebSocketConnection) -> None:
|
||||
self.conn = conn
|
||||
|
||||
def set_geolocation_override(
|
||||
self,
|
||||
coordinates: GeolocationCoordinates | None = None,
|
||||
error: GeolocationPositionError | None = None,
|
||||
contexts: list[str] | None = None,
|
||||
user_contexts: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Set geolocation override for the given contexts or user contexts.
|
||||
|
||||
Args:
|
||||
coordinates: Geolocation coordinates to emulate, or None.
|
||||
error: Geolocation error to emulate, or None.
|
||||
contexts: List of browsing context IDs to apply the override to.
|
||||
user_contexts: List of user context IDs to apply the override to.
|
||||
|
||||
Raises:
|
||||
ValueError: If both coordinates and error are provided, or if both contexts
|
||||
and user_contexts are provided, or if neither contexts nor
|
||||
user_contexts are provided.
|
||||
"""
|
||||
if coordinates is not None and error is not None:
|
||||
raise ValueError("Cannot specify both coordinates and error")
|
||||
|
||||
if contexts is not None and user_contexts is not None:
|
||||
raise ValueError("Cannot specify both contexts and userContexts")
|
||||
|
||||
if contexts is None and user_contexts is None:
|
||||
raise ValueError("Must specify either contexts or userContexts")
|
||||
|
||||
params: dict[str, Any] = {}
|
||||
|
||||
if coordinates is not None:
|
||||
params["coordinates"] = coordinates.to_dict()
|
||||
elif error is not None:
|
||||
params["error"] = error.to_dict()
|
||||
|
||||
if contexts is not None:
|
||||
params["contexts"] = contexts
|
||||
elif user_contexts is not None:
|
||||
params["userContexts"] = user_contexts
|
||||
|
||||
self.conn.execute(command_builder("emulation.setGeolocationOverride", params))
|
||||
|
||||
def set_timezone_override(
|
||||
self,
|
||||
timezone: str | None = None,
|
||||
contexts: list[str] | None = None,
|
||||
user_contexts: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Set timezone override for the given contexts or user contexts.
|
||||
|
||||
Args:
|
||||
timezone: Timezone identifier (IANA timezone name or offset string like '+01:00'),
|
||||
or None to clear the override.
|
||||
contexts: List of browsing context IDs to apply the override to.
|
||||
user_contexts: List of user context IDs to apply the override to.
|
||||
|
||||
Raises:
|
||||
ValueError: If both contexts and user_contexts are provided, or if neither
|
||||
contexts nor user_contexts are provided.
|
||||
"""
|
||||
if contexts is not None and user_contexts is not None:
|
||||
raise ValueError("Cannot specify both contexts and user_contexts")
|
||||
|
||||
if contexts is None and user_contexts is None:
|
||||
raise ValueError("Must specify either contexts or user_contexts")
|
||||
|
||||
params: dict[str, Any] = {"timezone": timezone}
|
||||
|
||||
if contexts is not None:
|
||||
params["contexts"] = contexts
|
||||
elif user_contexts is not None:
|
||||
params["userContexts"] = user_contexts
|
||||
|
||||
self.conn.execute(command_builder("emulation.setTimezoneOverride", params))
|
||||
|
||||
def set_locale_override(
|
||||
self,
|
||||
locale: str | None = None,
|
||||
contexts: list[str] | None = None,
|
||||
user_contexts: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Set locale override for the given contexts or user contexts.
|
||||
|
||||
Args:
|
||||
locale: Locale string as per BCP 47, or None to clear override.
|
||||
contexts: List of browsing context IDs to apply the override to.
|
||||
user_contexts: List of user context IDs to apply the override to.
|
||||
|
||||
Raises:
|
||||
ValueError: If both contexts and user_contexts are provided, or if neither
|
||||
contexts nor user_contexts are provided, or if locale is invalid.
|
||||
"""
|
||||
if contexts is not None and user_contexts is not None:
|
||||
raise ValueError("Cannot specify both contexts and userContexts")
|
||||
|
||||
if contexts is None and user_contexts is None:
|
||||
raise ValueError("Must specify either contexts or userContexts")
|
||||
|
||||
params: dict[str, Any] = {"locale": locale}
|
||||
|
||||
if contexts is not None:
|
||||
params["contexts"] = contexts
|
||||
elif user_contexts is not None:
|
||||
params["userContexts"] = user_contexts
|
||||
|
||||
self.conn.execute(command_builder("emulation.setLocaleOverride", params))
|
||||
|
||||
def set_scripting_enabled(
|
||||
self,
|
||||
enabled: bool | None = False,
|
||||
contexts: list[str] | None = None,
|
||||
user_contexts: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Set scripting enabled override for the given contexts or user contexts.
|
||||
|
||||
Args:
|
||||
enabled: False to disable scripting, None to clear the override.
|
||||
Note: Only emulation of disabled JavaScript is supported.
|
||||
contexts: List of browsing context IDs to apply the override to.
|
||||
user_contexts: List of user context IDs to apply the override to.
|
||||
|
||||
Raises:
|
||||
ValueError: If both contexts and user_contexts are provided, or if neither
|
||||
contexts nor user_contexts are provided, or if enabled is True.
|
||||
"""
|
||||
if enabled:
|
||||
raise ValueError("Only emulation of disabled JavaScript is supported (enabled must be False or None)")
|
||||
|
||||
if contexts is not None and user_contexts is not None:
|
||||
raise ValueError("Cannot specify both contexts and userContexts")
|
||||
|
||||
if contexts is None and user_contexts is None:
|
||||
raise ValueError("Must specify either contexts or userContexts")
|
||||
|
||||
params: dict[str, Any] = {"enabled": enabled}
|
||||
|
||||
if contexts is not None:
|
||||
params["contexts"] = contexts
|
||||
elif user_contexts is not None:
|
||||
params["userContexts"] = user_contexts
|
||||
|
||||
self.conn.execute(command_builder("emulation.setScriptingEnabled", params))
|
||||
|
||||
def set_screen_orientation_override(
|
||||
self,
|
||||
screen_orientation: ScreenOrientation | None = None,
|
||||
contexts: list[str] | None = None,
|
||||
user_contexts: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Set screen orientation override for the given contexts or user contexts.
|
||||
|
||||
Args:
|
||||
screen_orientation: ScreenOrientation object to emulate, or None to clear the override.
|
||||
contexts: List of browsing context IDs to apply the override to.
|
||||
user_contexts: List of user context IDs to apply the override to.
|
||||
|
||||
Raises:
|
||||
ValueError: If both contexts and user_contexts are provided, or if neither
|
||||
contexts nor user_contexts are provided.
|
||||
"""
|
||||
if contexts is not None and user_contexts is not None:
|
||||
raise ValueError("Cannot specify both contexts and userContexts")
|
||||
|
||||
if contexts is None and user_contexts is None:
|
||||
raise ValueError("Must specify either contexts or userContexts")
|
||||
|
||||
params: dict[str, Any] = {
|
||||
"screenOrientation": screen_orientation.to_dict() if screen_orientation is not None else None
|
||||
}
|
||||
|
||||
if contexts is not None:
|
||||
params["contexts"] = contexts
|
||||
elif user_contexts is not None:
|
||||
params["userContexts"] = user_contexts
|
||||
|
||||
self.conn.execute(command_builder("emulation.setScreenOrientationOverride", params))
|
||||
|
||||
def set_user_agent_override(
|
||||
self,
|
||||
user_agent: str | None = None,
|
||||
contexts: list[str] | None = None,
|
||||
user_contexts: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Set user agent override for the given contexts or user contexts.
|
||||
|
||||
Args:
|
||||
user_agent: User agent string to emulate, or None to clear the override.
|
||||
contexts: List of browsing context IDs to apply the override to.
|
||||
user_contexts: List of user context IDs to apply the override to.
|
||||
|
||||
Raises:
|
||||
ValueError: If both contexts and user_contexts are provided, or if neither
|
||||
contexts nor user_contexts are provided.
|
||||
"""
|
||||
if contexts is not None and user_contexts is not None:
|
||||
raise ValueError("Cannot specify both contexts and user_contexts")
|
||||
|
||||
if contexts is None and user_contexts is None:
|
||||
raise ValueError("Must specify either contexts or user_contexts")
|
||||
|
||||
params: dict[str, Any] = {"userAgent": user_agent}
|
||||
|
||||
if contexts is not None:
|
||||
params["contexts"] = contexts
|
||||
elif user_contexts is not None:
|
||||
params["userContexts"] = user_contexts
|
||||
|
||||
self.conn.execute(command_builder("emulation.setUserAgentOverride", params))
|
||||
|
||||
def set_network_conditions(
|
||||
self,
|
||||
offline: bool = False,
|
||||
contexts: list[str] | None = None,
|
||||
user_contexts: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Set network conditions for the given contexts or user contexts.
|
||||
|
||||
Args:
|
||||
offline: True to emulate offline network conditions, False to clear the override.
|
||||
contexts: List of browsing context IDs to apply the conditions to.
|
||||
user_contexts: List of user context IDs to apply the conditions to.
|
||||
|
||||
Raises:
|
||||
ValueError: If both contexts and user_contexts are provided, or if neither
|
||||
contexts nor user_contexts are provided.
|
||||
"""
|
||||
if contexts is not None and user_contexts is not None:
|
||||
raise ValueError("Cannot specify both contexts and user_contexts")
|
||||
|
||||
if contexts is None and user_contexts is None:
|
||||
raise ValueError("Must specify either contexts or user_contexts")
|
||||
|
||||
params: dict[str, Any] = {}
|
||||
|
||||
if offline:
|
||||
params["networkConditions"] = {"type": "offline"}
|
||||
else:
|
||||
# if offline is False or None, then clear the override
|
||||
params["networkConditions"] = None
|
||||
|
||||
if contexts is not None:
|
||||
params["contexts"] = contexts
|
||||
elif user_contexts is not None:
|
||||
params["userContexts"] = user_contexts
|
||||
|
||||
self.conn.execute(command_builder("emulation.setNetworkConditions", params))
|
||||
|
||||
def set_screen_settings_override(
|
||||
self,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
contexts: list[str] | None = None,
|
||||
user_contexts: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Set screen settings override for the given contexts or user contexts.
|
||||
|
||||
Args:
|
||||
width: Screen width in pixels (>= 0). None to clear the override.
|
||||
height: Screen height in pixels (>= 0). None to clear the override.
|
||||
contexts: List of browsing context IDs to apply the override to.
|
||||
user_contexts: List of user context IDs to apply the override to.
|
||||
|
||||
Raises:
|
||||
ValueError: If only one of width/height is provided, or if both contexts
|
||||
and user_contexts are provided, or if neither is provided.
|
||||
"""
|
||||
if (width is None) != (height is None):
|
||||
raise ValueError("Must provide both width and height, or neither to clear the override")
|
||||
|
||||
if contexts is not None and user_contexts is not None:
|
||||
raise ValueError("Cannot specify both contexts and user_contexts")
|
||||
|
||||
if contexts is None and user_contexts is None:
|
||||
raise ValueError("Must specify either contexts or user_contexts")
|
||||
|
||||
screen_area = None
|
||||
if width is not None and height is not None:
|
||||
if not isinstance(width, int) or not isinstance(height, int):
|
||||
raise ValueError("width and height must be integers")
|
||||
if width < 0 or height < 0:
|
||||
raise ValueError("width and height must be >= 0")
|
||||
screen_area = {"width": width, "height": height}
|
||||
|
||||
params: dict[str, Any] = {"screenArea": screen_area}
|
||||
|
||||
if contexts is not None:
|
||||
params["contexts"] = contexts
|
||||
elif user_contexts is not None:
|
||||
params["userContexts"] = user_contexts
|
||||
|
||||
self.conn.execute(command_builder("emulation.setScreenSettingsOverride", params))
|
||||
@@ -0,0 +1,462 @@
|
||||
# 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.
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from selenium.webdriver.common.bidi.common import command_builder
|
||||
from selenium.webdriver.common.bidi.session import Session
|
||||
|
||||
|
||||
class PointerType:
|
||||
"""Represents the possible pointer types."""
|
||||
|
||||
MOUSE = "mouse"
|
||||
PEN = "pen"
|
||||
TOUCH = "touch"
|
||||
|
||||
VALID_TYPES = {MOUSE, PEN, TOUCH}
|
||||
|
||||
|
||||
class Origin:
|
||||
"""Represents the possible origin types."""
|
||||
|
||||
VIEWPORT = "viewport"
|
||||
POINTER = "pointer"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ElementOrigin:
|
||||
"""Represents an element origin for input actions."""
|
||||
|
||||
type: str
|
||||
element: dict
|
||||
|
||||
def __init__(self, element_reference: dict):
|
||||
self.type = "element"
|
||||
self.element = element_reference
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the ElementOrigin to a dictionary."""
|
||||
return {"type": self.type, "element": self.element}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PointerParameters:
|
||||
"""Represents pointer parameters for pointer actions."""
|
||||
|
||||
pointer_type: str = PointerType.MOUSE
|
||||
|
||||
def __post_init__(self):
|
||||
if self.pointer_type not in PointerType.VALID_TYPES:
|
||||
raise ValueError(f"Invalid pointer type: {self.pointer_type}. Must be one of {PointerType.VALID_TYPES}")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the PointerParameters to a dictionary."""
|
||||
return {"pointerType": self.pointer_type}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PointerCommonProperties:
|
||||
"""Common properties for pointer actions."""
|
||||
|
||||
width: int = 1
|
||||
height: int = 1
|
||||
pressure: float = 0.0
|
||||
tangential_pressure: float = 0.0
|
||||
twist: int = 0
|
||||
altitude_angle: float = 0.0
|
||||
azimuth_angle: float = 0.0
|
||||
|
||||
def __post_init__(self):
|
||||
if self.width < 1:
|
||||
raise ValueError("width must be at least 1")
|
||||
if self.height < 1:
|
||||
raise ValueError("height must be at least 1")
|
||||
if not (0.0 <= self.pressure <= 1.0):
|
||||
raise ValueError("pressure must be between 0.0 and 1.0")
|
||||
if not (0.0 <= self.tangential_pressure <= 1.0):
|
||||
raise ValueError("tangential_pressure must be between 0.0 and 1.0")
|
||||
if not (0 <= self.twist <= 359):
|
||||
raise ValueError("twist must be between 0 and 359")
|
||||
if not (0.0 <= self.altitude_angle <= math.pi / 2):
|
||||
raise ValueError("altitude_angle must be between 0.0 and π/2")
|
||||
if not (0.0 <= self.azimuth_angle <= 2 * math.pi):
|
||||
raise ValueError("azimuth_angle must be between 0.0 and 2π")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the PointerCommonProperties to a dictionary."""
|
||||
result: dict[str, Any] = {}
|
||||
if self.width != 1:
|
||||
result["width"] = self.width
|
||||
if self.height != 1:
|
||||
result["height"] = self.height
|
||||
if self.pressure != 0.0:
|
||||
result["pressure"] = self.pressure
|
||||
if self.tangential_pressure != 0.0:
|
||||
result["tangentialPressure"] = self.tangential_pressure
|
||||
if self.twist != 0:
|
||||
result["twist"] = self.twist
|
||||
if self.altitude_angle != 0.0:
|
||||
result["altitudeAngle"] = self.altitude_angle
|
||||
if self.azimuth_angle != 0.0:
|
||||
result["azimuthAngle"] = self.azimuth_angle
|
||||
return result
|
||||
|
||||
|
||||
# Action classes
|
||||
@dataclass
|
||||
class PauseAction:
|
||||
"""Represents a pause action."""
|
||||
|
||||
duration: int | None = None
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "pause"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the PauseAction to a dictionary."""
|
||||
result: dict[str, Any] = {"type": self.type}
|
||||
if self.duration is not None:
|
||||
result["duration"] = self.duration
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyDownAction:
|
||||
"""Represents a key down action."""
|
||||
|
||||
value: str = ""
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "keyDown"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the KeyDownAction to a dictionary."""
|
||||
return {"type": self.type, "value": self.value}
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyUpAction:
|
||||
"""Represents a key up action."""
|
||||
|
||||
value: str = ""
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "keyUp"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the KeyUpAction to a dictionary."""
|
||||
return {"type": self.type, "value": self.value}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PointerDownAction:
|
||||
"""Represents a pointer down action."""
|
||||
|
||||
button: int = 0
|
||||
properties: PointerCommonProperties | None = None
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "pointerDown"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the PointerDownAction to a dictionary."""
|
||||
result: dict[str, Any] = {"type": self.type, "button": self.button}
|
||||
if self.properties:
|
||||
result.update(self.properties.to_dict())
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class PointerUpAction:
|
||||
"""Represents a pointer up action."""
|
||||
|
||||
button: int = 0
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "pointerUp"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the PointerUpAction to a dictionary."""
|
||||
return {"type": self.type, "button": self.button}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PointerMoveAction:
|
||||
"""Represents a pointer move action."""
|
||||
|
||||
x: float = 0
|
||||
y: float = 0
|
||||
duration: int | None = None
|
||||
origin: str | ElementOrigin | None = None
|
||||
properties: PointerCommonProperties | None = None
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "pointerMove"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the PointerMoveAction to a dictionary."""
|
||||
result: dict[str, Any] = {"type": self.type, "x": self.x, "y": self.y}
|
||||
if self.duration is not None:
|
||||
result["duration"] = self.duration
|
||||
if self.origin is not None:
|
||||
if isinstance(self.origin, ElementOrigin):
|
||||
result["origin"] = self.origin.to_dict()
|
||||
else:
|
||||
result["origin"] = self.origin
|
||||
if self.properties:
|
||||
result.update(self.properties.to_dict())
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class WheelScrollAction:
|
||||
"""Represents a wheel scroll action."""
|
||||
|
||||
x: int = 0
|
||||
y: int = 0
|
||||
delta_x: int = 0
|
||||
delta_y: int = 0
|
||||
duration: int | None = None
|
||||
origin: str | ElementOrigin | None = Origin.VIEWPORT
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "scroll"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the WheelScrollAction to a dictionary."""
|
||||
result: dict[str, Any] = {
|
||||
"type": self.type,
|
||||
"x": self.x,
|
||||
"y": self.y,
|
||||
"deltaX": self.delta_x,
|
||||
"deltaY": self.delta_y,
|
||||
}
|
||||
if self.duration is not None:
|
||||
result["duration"] = self.duration
|
||||
if self.origin is not None:
|
||||
if isinstance(self.origin, ElementOrigin):
|
||||
result["origin"] = self.origin.to_dict()
|
||||
else:
|
||||
result["origin"] = self.origin
|
||||
return result
|
||||
|
||||
|
||||
# Source Actions
|
||||
@dataclass
|
||||
class NoneSourceActions:
|
||||
"""Represents a sequence of none actions."""
|
||||
|
||||
id: str = ""
|
||||
actions: list[PauseAction] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "none"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the NoneSourceActions to a dictionary."""
|
||||
return {"type": self.type, "id": self.id, "actions": [action.to_dict() for action in self.actions]}
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeySourceActions:
|
||||
"""Represents a sequence of key actions."""
|
||||
|
||||
id: str = ""
|
||||
actions: list[PauseAction | KeyDownAction | KeyUpAction] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "key"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the KeySourceActions to a dictionary."""
|
||||
return {"type": self.type, "id": self.id, "actions": [action.to_dict() for action in self.actions]}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PointerSourceActions:
|
||||
"""Represents a sequence of pointer actions."""
|
||||
|
||||
id: str = ""
|
||||
parameters: PointerParameters | None = None
|
||||
actions: list[PauseAction | PointerDownAction | PointerUpAction | PointerMoveAction] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.parameters is None:
|
||||
self.parameters = PointerParameters()
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "pointer"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the PointerSourceActions to a dictionary."""
|
||||
result: dict[str, Any] = {
|
||||
"type": self.type,
|
||||
"id": self.id,
|
||||
"actions": [action.to_dict() for action in self.actions],
|
||||
}
|
||||
if self.parameters:
|
||||
result["parameters"] = self.parameters.to_dict()
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class WheelSourceActions:
|
||||
"""Represents a sequence of wheel actions."""
|
||||
|
||||
id: str = ""
|
||||
actions: list[PauseAction | WheelScrollAction] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "wheel"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert the WheelSourceActions to a dictionary."""
|
||||
return {"type": self.type, "id": self.id, "actions": [action.to_dict() for action in self.actions]}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileDialogInfo:
|
||||
"""Represents file dialog information from input.fileDialogOpened event."""
|
||||
|
||||
context: str
|
||||
multiple: bool
|
||||
element: dict | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "FileDialogInfo":
|
||||
"""Creates a FileDialogInfo instance from a dictionary.
|
||||
|
||||
Args:
|
||||
data: A dictionary containing the file dialog information.
|
||||
|
||||
Returns:
|
||||
FileDialogInfo: A new instance of FileDialogInfo.
|
||||
"""
|
||||
return cls(context=data["context"], multiple=data["multiple"], element=data.get("element"))
|
||||
|
||||
|
||||
# Event Class
|
||||
class FileDialogOpened:
|
||||
"""Event class for input.fileDialogOpened event."""
|
||||
|
||||
event_class = "input.fileDialogOpened"
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json):
|
||||
"""Create FileDialogInfo from JSON data."""
|
||||
return FileDialogInfo.from_dict(json)
|
||||
|
||||
|
||||
class Input:
|
||||
"""BiDi implementation of the input module."""
|
||||
|
||||
def __init__(self, conn):
|
||||
self.conn = conn
|
||||
self.subscriptions = {}
|
||||
self.callbacks = {}
|
||||
|
||||
def perform_actions(
|
||||
self,
|
||||
context: str,
|
||||
actions: list[NoneSourceActions | KeySourceActions | PointerSourceActions | WheelSourceActions],
|
||||
) -> None:
|
||||
"""Performs a sequence of user input actions.
|
||||
|
||||
Args:
|
||||
context: The browsing context ID where actions should be performed.
|
||||
actions: A list of source actions to perform.
|
||||
"""
|
||||
params = {"context": context, "actions": [action.to_dict() for action in actions]}
|
||||
self.conn.execute(command_builder("input.performActions", params))
|
||||
|
||||
def release_actions(self, context: str) -> None:
|
||||
"""Releases all input state for the given context.
|
||||
|
||||
Args:
|
||||
context: The browsing context ID to release actions for.
|
||||
"""
|
||||
params = {"context": context}
|
||||
self.conn.execute(command_builder("input.releaseActions", params))
|
||||
|
||||
def set_files(self, context: str, element: dict, files: list[str]) -> None:
|
||||
"""Sets files for a file input element.
|
||||
|
||||
Args:
|
||||
context: The browsing context ID.
|
||||
element: The element reference (script.SharedReference).
|
||||
files: A list of file paths to set.
|
||||
"""
|
||||
params = {"context": context, "element": element, "files": files}
|
||||
self.conn.execute(command_builder("input.setFiles", params))
|
||||
|
||||
def add_file_dialog_handler(self, handler) -> int:
|
||||
"""Add a handler for file dialog opened events.
|
||||
|
||||
Args:
|
||||
handler: Callback function that takes a FileDialogInfo object.
|
||||
|
||||
Returns:
|
||||
int: Callback ID for removing the handler later.
|
||||
"""
|
||||
# Subscribe to the event if not already subscribed
|
||||
if FileDialogOpened.event_class not in self.subscriptions:
|
||||
session = Session(self.conn)
|
||||
self.conn.execute(session.subscribe(FileDialogOpened.event_class))
|
||||
self.subscriptions[FileDialogOpened.event_class] = []
|
||||
|
||||
# Add callback - the callback receives the parsed FileDialogInfo directly
|
||||
callback_id = self.conn.add_callback(FileDialogOpened, handler)
|
||||
|
||||
self.subscriptions[FileDialogOpened.event_class].append(callback_id)
|
||||
self.callbacks[callback_id] = handler
|
||||
|
||||
return callback_id
|
||||
|
||||
def remove_file_dialog_handler(self, callback_id: int) -> None:
|
||||
"""Remove a file dialog handler.
|
||||
|
||||
Args:
|
||||
callback_id: The callback ID returned by add_file_dialog_handler.
|
||||
"""
|
||||
if callback_id in self.callbacks:
|
||||
del self.callbacks[callback_id]
|
||||
|
||||
if FileDialogOpened.event_class in self.subscriptions:
|
||||
if callback_id in self.subscriptions[FileDialogOpened.event_class]:
|
||||
self.subscriptions[FileDialogOpened.event_class].remove(callback_id)
|
||||
|
||||
# If no more callbacks for this event, unsubscribe
|
||||
if not self.subscriptions[FileDialogOpened.event_class]:
|
||||
session = Session(self.conn)
|
||||
self.conn.execute(session.unsubscribe(FileDialogOpened.event_class))
|
||||
del self.subscriptions[FileDialogOpened.event_class]
|
||||
|
||||
self.conn.remove_callback(FileDialogOpened, callback_id)
|
||||
@@ -0,0 +1,81 @@
|
||||
# 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.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
class LogEntryAdded:
|
||||
event_class = "log.entryAdded"
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json: dict[str, Any]) -> ConsoleLogEntry | JavaScriptLogEntry | None:
|
||||
if json["type"] == "console":
|
||||
return ConsoleLogEntry.from_json(json)
|
||||
elif json["type"] == "javascript":
|
||||
return JavaScriptLogEntry.from_json(json)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConsoleLogEntry:
|
||||
level: str
|
||||
text: str
|
||||
timestamp: str
|
||||
method: str
|
||||
args: list[dict[str, Any]]
|
||||
type_: str
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json: dict[str, Any]) -> ConsoleLogEntry:
|
||||
return cls(
|
||||
level=json["level"],
|
||||
text=json["text"],
|
||||
timestamp=json["timestamp"],
|
||||
method=json["method"],
|
||||
args=json["args"],
|
||||
type_=json["type"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JavaScriptLogEntry:
|
||||
level: str
|
||||
text: str
|
||||
timestamp: str
|
||||
stacktrace: dict[str, Any]
|
||||
type_: str
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json: dict[str, Any]) -> JavaScriptLogEntry:
|
||||
return cls(
|
||||
level=json["level"],
|
||||
text=json["text"],
|
||||
timestamp=json["timestamp"],
|
||||
stacktrace=json["stackTrace"],
|
||||
type_=json["type"],
|
||||
)
|
||||
|
||||
|
||||
class LogLevel:
|
||||
"""Represents log level."""
|
||||
|
||||
DEBUG = "debug"
|
||||
INFO = "info"
|
||||
WARN = "warn"
|
||||
ERROR = "error"
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
# 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.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from selenium.webdriver.common.bidi.common import command_builder
|
||||
from selenium.webdriver.remote.websocket_connection import WebSocketConnection
|
||||
|
||||
|
||||
class NetworkEvent:
|
||||
"""Represents a network event."""
|
||||
|
||||
def __init__(self, event_class: str, **kwargs: Any) -> None:
|
||||
self.event_class = event_class
|
||||
self.params = kwargs
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json: dict[str, Any]) -> NetworkEvent:
|
||||
return cls(event_class=json.get("event_class", ""), **json)
|
||||
|
||||
|
||||
class Network:
|
||||
EVENTS = {
|
||||
"before_request": "network.beforeRequestSent",
|
||||
"response_started": "network.responseStarted",
|
||||
"response_completed": "network.responseCompleted",
|
||||
"auth_required": "network.authRequired",
|
||||
"fetch_error": "network.fetchError",
|
||||
"continue_request": "network.continueRequest",
|
||||
"continue_auth": "network.continueWithAuth",
|
||||
}
|
||||
|
||||
PHASES = {
|
||||
"before_request": "beforeRequestSent",
|
||||
"response_started": "responseStarted",
|
||||
"auth_required": "authRequired",
|
||||
}
|
||||
|
||||
def __init__(self, conn: WebSocketConnection) -> None:
|
||||
self.conn = conn
|
||||
self.intercepts: list[str] = []
|
||||
self.callbacks: dict[str | int, Any] = {}
|
||||
self.subscriptions: dict[str, list[int]] = {}
|
||||
|
||||
def _add_intercept(
|
||||
self,
|
||||
phases: list[str] | None = None,
|
||||
contexts: list[str] | None = None,
|
||||
url_patterns: list[Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Add an intercept to the network.
|
||||
|
||||
Args:
|
||||
phases: A list of phases to intercept. Default is None (empty list).
|
||||
contexts: A list of contexts to intercept. Default is None.
|
||||
url_patterns: A list of URL patterns to intercept. Default is None.
|
||||
|
||||
Returns:
|
||||
str: intercept id
|
||||
"""
|
||||
if phases is None:
|
||||
phases = []
|
||||
params = {}
|
||||
if contexts is not None:
|
||||
params["contexts"] = contexts
|
||||
if url_patterns is not None:
|
||||
params["urlPatterns"] = url_patterns
|
||||
if len(phases) > 0:
|
||||
params["phases"] = phases
|
||||
else:
|
||||
params["phases"] = ["beforeRequestSent"]
|
||||
cmd = command_builder("network.addIntercept", params)
|
||||
|
||||
result: dict[str, Any] = self.conn.execute(cmd)
|
||||
self.intercepts.append(result["intercept"])
|
||||
return result
|
||||
|
||||
def _remove_intercept(self, intercept: str | None = None) -> None:
|
||||
"""Remove a specific intercept, or all intercepts.
|
||||
|
||||
Args:
|
||||
intercept: The intercept to remove. Default is None.
|
||||
|
||||
Raises:
|
||||
ValueError: If intercept is not found.
|
||||
|
||||
Note:
|
||||
If intercept is None, all intercepts will be removed.
|
||||
"""
|
||||
if intercept is None:
|
||||
intercepts_to_remove = self.intercepts.copy() # create a copy before iterating
|
||||
for intercept_id in intercepts_to_remove: # remove all intercepts
|
||||
self.conn.execute(command_builder("network.removeIntercept", {"intercept": intercept_id}))
|
||||
self.intercepts.remove(intercept_id)
|
||||
else:
|
||||
try:
|
||||
self.conn.execute(command_builder("network.removeIntercept", {"intercept": intercept}))
|
||||
self.intercepts.remove(intercept)
|
||||
except Exception as e:
|
||||
raise Exception(f"Exception: {e}")
|
||||
|
||||
def _on_request(self, event_name: str, callback: Callable[[Request], Any]) -> int:
|
||||
"""Set a callback function to subscribe to a network event.
|
||||
|
||||
Args:
|
||||
event_name: The event to subscribe to.
|
||||
callback: The callback function to execute on event.
|
||||
Takes Request object as argument.
|
||||
|
||||
Returns:
|
||||
int: callback id
|
||||
"""
|
||||
event = NetworkEvent(event_name)
|
||||
|
||||
def _callback(event_data: NetworkEvent) -> None:
|
||||
request = Request(
|
||||
network=self,
|
||||
request_id=event_data.params["request"].get("request", None),
|
||||
body_size=event_data.params["request"].get("bodySize", None),
|
||||
cookies=event_data.params["request"].get("cookies", None),
|
||||
resource_type=event_data.params["request"].get("goog:resourceType", None),
|
||||
headers=event_data.params["request"].get("headers", None),
|
||||
headers_size=event_data.params["request"].get("headersSize", None),
|
||||
timings=event_data.params["request"].get("timings", None),
|
||||
url=event_data.params["request"].get("url", None),
|
||||
)
|
||||
callback(request)
|
||||
|
||||
callback_id: int = self.conn.add_callback(event, _callback)
|
||||
|
||||
if event_name in self.callbacks:
|
||||
self.callbacks[event_name].append(callback_id)
|
||||
else:
|
||||
self.callbacks[event_name] = [callback_id]
|
||||
|
||||
return callback_id
|
||||
|
||||
def add_request_handler(
|
||||
self,
|
||||
event: str,
|
||||
callback: Callable[[Request], Any],
|
||||
url_patterns: list[Any] | None = None,
|
||||
contexts: list[str] | None = None,
|
||||
) -> int:
|
||||
"""Add a request handler to the network.
|
||||
|
||||
Args:
|
||||
event: The event to subscribe to.
|
||||
callback: The callback function to execute on request interception.
|
||||
Takes Request object as argument.
|
||||
url_patterns: A list of URL patterns to intercept. Default is None.
|
||||
contexts: A list of contexts to intercept. Default is None.
|
||||
|
||||
Returns:
|
||||
int: callback id
|
||||
"""
|
||||
try:
|
||||
event_name = self.EVENTS[event]
|
||||
phase_name = self.PHASES[event]
|
||||
except KeyError:
|
||||
raise Exception(f"Event {event} not found")
|
||||
|
||||
result = self._add_intercept(phases=[phase_name], url_patterns=url_patterns, contexts=contexts)
|
||||
callback_id = self._on_request(event_name, callback)
|
||||
|
||||
if event_name in self.subscriptions:
|
||||
self.subscriptions[event_name].append(callback_id)
|
||||
else:
|
||||
params: dict[str, Any] = {}
|
||||
params["events"] = [event_name]
|
||||
self.conn.execute(command_builder("session.subscribe", params))
|
||||
self.subscriptions[event_name] = [callback_id]
|
||||
|
||||
self.callbacks[callback_id] = result["intercept"]
|
||||
return callback_id
|
||||
|
||||
def remove_request_handler(self, event: str, callback_id: int) -> None:
|
||||
"""Remove a request handler from the network.
|
||||
|
||||
Args:
|
||||
event: The event to unsubscribe from.
|
||||
callback_id: The callback id to remove.
|
||||
"""
|
||||
try:
|
||||
event_name = self.EVENTS[event]
|
||||
except KeyError:
|
||||
raise Exception(f"Event {event} not found")
|
||||
|
||||
net_event = NetworkEvent(event_name)
|
||||
|
||||
self.conn.remove_callback(net_event, callback_id)
|
||||
self._remove_intercept(self.callbacks[callback_id])
|
||||
del self.callbacks[callback_id]
|
||||
self.subscriptions[event_name].remove(callback_id)
|
||||
if len(self.subscriptions[event_name]) == 0:
|
||||
params: dict[str, Any] = {}
|
||||
params["events"] = [event_name]
|
||||
self.conn.execute(command_builder("session.unsubscribe", params))
|
||||
del self.subscriptions[event_name]
|
||||
|
||||
def clear_request_handlers(self) -> None:
|
||||
"""Clear all request handlers from the network."""
|
||||
for event_name in self.subscriptions:
|
||||
net_event = NetworkEvent(event_name)
|
||||
for callback_id in self.subscriptions[event_name]:
|
||||
self.conn.remove_callback(net_event, callback_id)
|
||||
self._remove_intercept(self.callbacks[callback_id])
|
||||
del self.callbacks[callback_id]
|
||||
params: dict[str, Any] = {}
|
||||
params["events"] = [event_name]
|
||||
self.conn.execute(command_builder("session.unsubscribe", params))
|
||||
self.subscriptions = {}
|
||||
|
||||
def add_auth_handler(self, username: str, password: str) -> int:
|
||||
"""Add an authentication handler to the network.
|
||||
|
||||
Args:
|
||||
username: The username to authenticate with.
|
||||
password: The password to authenticate with.
|
||||
|
||||
Returns:
|
||||
int: callback id
|
||||
"""
|
||||
event = "auth_required"
|
||||
|
||||
def _callback(request: Request) -> None:
|
||||
request._continue_with_auth(username, password)
|
||||
|
||||
return self.add_request_handler(event, _callback)
|
||||
|
||||
def remove_auth_handler(self, callback_id: int) -> None:
|
||||
"""Remove an authentication handler from the network.
|
||||
|
||||
Args:
|
||||
callback_id: The callback id to remove.
|
||||
"""
|
||||
event = "auth_required"
|
||||
self.remove_request_handler(event, callback_id)
|
||||
|
||||
|
||||
class Request:
|
||||
"""Represents an intercepted network request."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
network: Network,
|
||||
request_id: Any,
|
||||
body_size: int | None = None,
|
||||
cookies: Any = None,
|
||||
resource_type: str | None = None,
|
||||
headers: Any = None,
|
||||
headers_size: int | None = None,
|
||||
method: str | None = None,
|
||||
timings: Any = None,
|
||||
url: str | None = None,
|
||||
) -> None:
|
||||
self.network = network
|
||||
self.request_id = request_id
|
||||
self.body_size = body_size
|
||||
self.cookies = cookies
|
||||
self.resource_type = resource_type
|
||||
self.headers = headers
|
||||
self.headers_size = headers_size
|
||||
self.method = method
|
||||
self.timings = timings
|
||||
self.url = url
|
||||
|
||||
def fail_request(self) -> None:
|
||||
"""Fail this request."""
|
||||
if not self.request_id:
|
||||
raise ValueError("Request not found.")
|
||||
|
||||
params: dict[str, Any] = {"request": self.request_id}
|
||||
self.network.conn.execute(command_builder("network.failRequest", params))
|
||||
|
||||
def continue_request(
|
||||
self,
|
||||
body: Any = None,
|
||||
method: str | None = None,
|
||||
headers: Any = None,
|
||||
cookies: Any = None,
|
||||
url: str | None = None,
|
||||
) -> None:
|
||||
"""Continue after intercepting this request."""
|
||||
if not self.request_id:
|
||||
raise ValueError("Request not found.")
|
||||
|
||||
params: dict[str, Any] = {"request": self.request_id}
|
||||
if body is not None:
|
||||
params["body"] = body
|
||||
if method is not None:
|
||||
params["method"] = method
|
||||
if headers is not None:
|
||||
params["headers"] = headers
|
||||
if cookies is not None:
|
||||
params["cookies"] = cookies
|
||||
if url is not None:
|
||||
params["url"] = url
|
||||
|
||||
self.network.conn.execute(command_builder("network.continueRequest", params))
|
||||
|
||||
def _continue_with_auth(self, username: str | None = None, password: str | None = None) -> None:
|
||||
"""Continue with authentication.
|
||||
|
||||
Args:
|
||||
username: The username to authenticate with.
|
||||
password: The password to authenticate with.
|
||||
|
||||
Note:
|
||||
If username or password is None, it attempts auth with no credentials.
|
||||
"""
|
||||
params: dict[str, Any] = {}
|
||||
params["request"] = self.request_id
|
||||
|
||||
if not username or not password: # no credentials is valid option
|
||||
params["action"] = "default"
|
||||
else:
|
||||
params["action"] = "provideCredentials"
|
||||
params["credentials"] = {"type": "password", "username": username, "password": password}
|
||||
|
||||
self.network.conn.execute(command_builder("network.continueWithAuth", params))
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
# 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.
|
||||
|
||||
|
||||
from selenium.webdriver.common.bidi.common import command_builder
|
||||
|
||||
|
||||
class PermissionState:
|
||||
"""Represents the possible permission states."""
|
||||
|
||||
GRANTED = "granted"
|
||||
DENIED = "denied"
|
||||
PROMPT = "prompt"
|
||||
|
||||
|
||||
class PermissionDescriptor:
|
||||
"""Represents a permission descriptor."""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"name": self.name}
|
||||
|
||||
|
||||
class Permissions:
|
||||
"""BiDi implementation of the permissions module."""
|
||||
|
||||
def __init__(self, conn):
|
||||
self.conn = conn
|
||||
|
||||
def set_permission(
|
||||
self,
|
||||
descriptor: str | PermissionDescriptor,
|
||||
state: str,
|
||||
origin: str,
|
||||
user_context: str | None = None,
|
||||
) -> None:
|
||||
"""Sets a permission state for a given permission descriptor.
|
||||
|
||||
Args:
|
||||
descriptor: The permission name (str) or PermissionDescriptor object.
|
||||
Examples: "geolocation", "camera", "microphone".
|
||||
state: The permission state (granted, denied, prompt).
|
||||
origin: The origin for which the permission is set.
|
||||
user_context: The user context id (optional).
|
||||
|
||||
Raises:
|
||||
ValueError: If the permission state is invalid.
|
||||
"""
|
||||
if state not in [PermissionState.GRANTED, PermissionState.DENIED, PermissionState.PROMPT]:
|
||||
valid_states = f"{PermissionState.GRANTED}, {PermissionState.DENIED}, {PermissionState.PROMPT}"
|
||||
raise ValueError(f"Invalid permission state. Must be one of: {valid_states}")
|
||||
|
||||
if isinstance(descriptor, str):
|
||||
permission_descriptor = PermissionDescriptor(descriptor)
|
||||
else:
|
||||
permission_descriptor = descriptor
|
||||
|
||||
params = {
|
||||
"descriptor": permission_descriptor.to_dict(),
|
||||
"state": state,
|
||||
"origin": origin,
|
||||
}
|
||||
|
||||
if user_context is not None:
|
||||
params["userContext"] = user_context
|
||||
|
||||
self.conn.execute(command_builder("permissions.setPermission", params))
|
||||
+547
@@ -0,0 +1,547 @@
|
||||
# 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.
|
||||
|
||||
import datetime
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from selenium.common.exceptions import WebDriverException
|
||||
from selenium.webdriver.common.bidi.common import command_builder
|
||||
from selenium.webdriver.common.bidi.log import LogEntryAdded
|
||||
from selenium.webdriver.common.bidi.session import Session
|
||||
|
||||
|
||||
class ResultOwnership:
|
||||
"""Represents the possible result ownership types."""
|
||||
|
||||
NONE = "none"
|
||||
ROOT = "root"
|
||||
|
||||
|
||||
class RealmType:
|
||||
"""Represents the possible realm types."""
|
||||
|
||||
WINDOW = "window"
|
||||
DEDICATED_WORKER = "dedicated-worker"
|
||||
SHARED_WORKER = "shared-worker"
|
||||
SERVICE_WORKER = "service-worker"
|
||||
WORKER = "worker"
|
||||
PAINT_WORKLET = "paint-worklet"
|
||||
AUDIO_WORKLET = "audio-worklet"
|
||||
WORKLET = "worklet"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RealmInfo:
|
||||
"""Represents information about a realm."""
|
||||
|
||||
realm: str
|
||||
origin: str
|
||||
type: str
|
||||
context: str | None = None
|
||||
sandbox: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json: dict[str, Any]) -> "RealmInfo":
|
||||
"""Creates a RealmInfo instance from a dictionary.
|
||||
|
||||
Args:
|
||||
json: A dictionary containing the realm information.
|
||||
|
||||
Returns:
|
||||
RealmInfo: A new instance of RealmInfo.
|
||||
"""
|
||||
if "realm" not in json:
|
||||
raise ValueError("Missing required field 'realm' in RealmInfo")
|
||||
if "origin" not in json:
|
||||
raise ValueError("Missing required field 'origin' in RealmInfo")
|
||||
if "type" not in json:
|
||||
raise ValueError("Missing required field 'type' in RealmInfo")
|
||||
|
||||
return cls(
|
||||
realm=json["realm"],
|
||||
origin=json["origin"],
|
||||
type=json["type"],
|
||||
context=json.get("context"),
|
||||
sandbox=json.get("sandbox"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Source:
|
||||
"""Represents the source of a script message."""
|
||||
|
||||
realm: str
|
||||
context: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json: dict[str, Any]) -> "Source":
|
||||
"""Creates a Source instance from a dictionary.
|
||||
|
||||
Args:
|
||||
json: A dictionary containing the source information.
|
||||
|
||||
Returns:
|
||||
Source: A new instance of Source.
|
||||
"""
|
||||
if "realm" not in json:
|
||||
raise ValueError("Missing required field 'realm' in Source")
|
||||
|
||||
return cls(
|
||||
realm=json["realm"],
|
||||
context=json.get("context"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvaluateResult:
|
||||
"""Represents the result of script evaluation."""
|
||||
|
||||
type: str
|
||||
realm: str
|
||||
result: dict | None = None
|
||||
exception_details: dict | None = None
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json: dict[str, Any]) -> "EvaluateResult":
|
||||
"""Creates an EvaluateResult instance from a dictionary.
|
||||
|
||||
Args:
|
||||
json: A dictionary containing the evaluation result.
|
||||
|
||||
Returns:
|
||||
EvaluateResult: A new instance of EvaluateResult.
|
||||
"""
|
||||
if "realm" not in json:
|
||||
raise ValueError("Missing required field 'realm' in EvaluateResult")
|
||||
if "type" not in json:
|
||||
raise ValueError("Missing required field 'type' in EvaluateResult")
|
||||
|
||||
return cls(
|
||||
type=json["type"],
|
||||
realm=json["realm"],
|
||||
result=json.get("result"),
|
||||
exception_details=json.get("exceptionDetails"),
|
||||
)
|
||||
|
||||
|
||||
class ScriptMessage:
|
||||
"""Represents a script message event."""
|
||||
|
||||
event_class = "script.message"
|
||||
|
||||
def __init__(self, channel: str, data: dict, source: Source):
|
||||
self.channel = channel
|
||||
self.data = data
|
||||
self.source = source
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json: dict[str, Any]) -> "ScriptMessage":
|
||||
"""Creates a ScriptMessage instance from a dictionary.
|
||||
|
||||
Args:
|
||||
json: A dictionary containing the script message.
|
||||
|
||||
Returns:
|
||||
ScriptMessage: A new instance of ScriptMessage.
|
||||
"""
|
||||
if "channel" not in json:
|
||||
raise ValueError("Missing required field 'channel' in ScriptMessage")
|
||||
if "data" not in json:
|
||||
raise ValueError("Missing required field 'data' in ScriptMessage")
|
||||
if "source" not in json:
|
||||
raise ValueError("Missing required field 'source' in ScriptMessage")
|
||||
|
||||
return cls(
|
||||
channel=json["channel"],
|
||||
data=json["data"],
|
||||
source=Source.from_json(json["source"]),
|
||||
)
|
||||
|
||||
|
||||
class RealmCreated:
|
||||
"""Represents a realm created event."""
|
||||
|
||||
event_class = "script.realmCreated"
|
||||
|
||||
def __init__(self, realm_info: RealmInfo):
|
||||
self.realm_info = realm_info
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json: dict[str, Any]) -> "RealmCreated":
|
||||
"""Creates a RealmCreated instance from a dictionary.
|
||||
|
||||
Args:
|
||||
json: A dictionary containing the realm created event.
|
||||
|
||||
Returns:
|
||||
RealmCreated: A new instance of RealmCreated.
|
||||
"""
|
||||
return cls(realm_info=RealmInfo.from_json(json))
|
||||
|
||||
|
||||
class RealmDestroyed:
|
||||
"""Represents a realm destroyed event."""
|
||||
|
||||
event_class = "script.realmDestroyed"
|
||||
|
||||
def __init__(self, realm: str):
|
||||
self.realm = realm
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json: dict[str, Any]) -> "RealmDestroyed":
|
||||
"""Creates a RealmDestroyed instance from a dictionary.
|
||||
|
||||
Args:
|
||||
json: A dictionary containing the realm destroyed event.
|
||||
|
||||
Returns:
|
||||
RealmDestroyed: A new instance of RealmDestroyed.
|
||||
"""
|
||||
if "realm" not in json:
|
||||
raise ValueError("Missing required field 'realm' in RealmDestroyed")
|
||||
|
||||
return cls(realm=json["realm"])
|
||||
|
||||
|
||||
class Script:
|
||||
"""BiDi implementation of the script module."""
|
||||
|
||||
EVENTS = {
|
||||
"message": "script.message",
|
||||
"realm_created": "script.realmCreated",
|
||||
"realm_destroyed": "script.realmDestroyed",
|
||||
}
|
||||
|
||||
def __init__(self, conn, driver=None):
|
||||
self.conn = conn
|
||||
self.driver = driver
|
||||
self.log_entry_subscribed = False
|
||||
self.subscriptions = {}
|
||||
self.callbacks = {}
|
||||
|
||||
# High-level APIs for SCRIPT module
|
||||
|
||||
def add_console_message_handler(self, handler):
|
||||
self._subscribe_to_log_entries()
|
||||
return self.conn.add_callback(LogEntryAdded, self._handle_log_entry("console", handler))
|
||||
|
||||
def add_javascript_error_handler(self, handler):
|
||||
self._subscribe_to_log_entries()
|
||||
return self.conn.add_callback(LogEntryAdded, self._handle_log_entry("javascript", handler))
|
||||
|
||||
def remove_console_message_handler(self, id):
|
||||
self.conn.remove_callback(LogEntryAdded, id)
|
||||
self._unsubscribe_from_log_entries()
|
||||
|
||||
remove_javascript_error_handler = remove_console_message_handler
|
||||
|
||||
def pin(self, script: str) -> str:
|
||||
"""Pins a script to the current browsing context.
|
||||
|
||||
Args:
|
||||
script: The script to pin.
|
||||
|
||||
Returns:
|
||||
str: The ID of the pinned script.
|
||||
"""
|
||||
return self._add_preload_script(script)
|
||||
|
||||
def unpin(self, script_id: str) -> None:
|
||||
"""Unpins a script from the current browsing context.
|
||||
|
||||
Args:
|
||||
script_id: The ID of the pinned script to unpin.
|
||||
"""
|
||||
self._remove_preload_script(script_id)
|
||||
|
||||
def execute(self, script: str, *args) -> dict:
|
||||
"""Executes a script in the current browsing context.
|
||||
|
||||
Args:
|
||||
script: The script function to execute.
|
||||
*args: Arguments to pass to the script function.
|
||||
|
||||
Returns:
|
||||
dict: The result value from the script execution.
|
||||
|
||||
Raises:
|
||||
WebDriverException: If the script execution fails.
|
||||
"""
|
||||
if self.driver is None:
|
||||
raise WebDriverException("Driver reference is required for script execution")
|
||||
browsing_context_id = self.driver.current_window_handle
|
||||
|
||||
# Convert arguments to the format expected by BiDi call_function (LocalValue Type)
|
||||
arguments = []
|
||||
for arg in args:
|
||||
arguments.append(self.__convert_to_local_value(arg))
|
||||
|
||||
target = {"context": browsing_context_id}
|
||||
|
||||
result = self._call_function(
|
||||
function_declaration=script, await_promise=True, target=target, arguments=arguments if arguments else None
|
||||
)
|
||||
|
||||
if result.type == "success":
|
||||
return result.result if result.result is not None else {}
|
||||
else:
|
||||
error_message = "Error while executing script"
|
||||
if result.exception_details:
|
||||
if "text" in result.exception_details:
|
||||
error_message += f": {result.exception_details['text']}"
|
||||
elif "message" in result.exception_details:
|
||||
error_message += f": {result.exception_details['message']}"
|
||||
|
||||
raise WebDriverException(error_message)
|
||||
|
||||
def __convert_to_local_value(self, value) -> dict:
|
||||
"""Converts a Python value to BiDi LocalValue format."""
|
||||
if value is None:
|
||||
return {"type": "null"}
|
||||
elif isinstance(value, bool):
|
||||
return {"type": "boolean", "value": value}
|
||||
elif isinstance(value, (int, float)):
|
||||
if isinstance(value, float):
|
||||
if math.isnan(value):
|
||||
return {"type": "number", "value": "NaN"}
|
||||
elif math.isinf(value):
|
||||
if value > 0:
|
||||
return {"type": "number", "value": "Infinity"}
|
||||
else:
|
||||
return {"type": "number", "value": "-Infinity"}
|
||||
elif value == 0.0 and math.copysign(1.0, value) < 0:
|
||||
return {"type": "number", "value": "-0"}
|
||||
|
||||
JS_MAX_SAFE_INTEGER = 9007199254740991
|
||||
if isinstance(value, int) and (value > JS_MAX_SAFE_INTEGER or value < -JS_MAX_SAFE_INTEGER):
|
||||
return {"type": "bigint", "value": str(value)}
|
||||
|
||||
return {"type": "number", "value": value}
|
||||
|
||||
elif isinstance(value, str):
|
||||
return {"type": "string", "value": value}
|
||||
elif isinstance(value, datetime.datetime):
|
||||
# Convert Python datetime to JavaScript Date (ISO 8601 format)
|
||||
return {"type": "date", "value": value.isoformat() + "Z" if value.tzinfo is None else value.isoformat()}
|
||||
elif isinstance(value, datetime.date):
|
||||
# Convert Python date to JavaScript Date
|
||||
dt = datetime.datetime.combine(value, datetime.time.min).replace(tzinfo=datetime.timezone.utc)
|
||||
return {"type": "date", "value": dt.isoformat()}
|
||||
elif isinstance(value, set):
|
||||
return {"type": "set", "value": [self.__convert_to_local_value(item) for item in value]}
|
||||
elif isinstance(value, (list, tuple)):
|
||||
return {"type": "array", "value": [self.__convert_to_local_value(item) for item in value]}
|
||||
elif isinstance(value, dict):
|
||||
return {
|
||||
"type": "object",
|
||||
"value": [
|
||||
[self.__convert_to_local_value(k), self.__convert_to_local_value(v)] for k, v in value.items()
|
||||
],
|
||||
}
|
||||
else:
|
||||
# For other types, convert to string
|
||||
return {"type": "string", "value": str(value)}
|
||||
|
||||
# low-level APIs for script module
|
||||
def _add_preload_script(
|
||||
self,
|
||||
function_declaration: str,
|
||||
arguments: list[dict[str, Any]] | None = None,
|
||||
contexts: list[str] | None = None,
|
||||
user_contexts: list[str] | None = None,
|
||||
sandbox: str | None = None,
|
||||
) -> str:
|
||||
"""Adds a preload script.
|
||||
|
||||
Args:
|
||||
function_declaration: The function declaration to preload.
|
||||
arguments: The arguments to pass to the function.
|
||||
contexts: The browsing context IDs to apply the script to.
|
||||
user_contexts: The user context IDs to apply the script to.
|
||||
sandbox: The sandbox name to apply the script to.
|
||||
|
||||
Returns:
|
||||
str: The preload script ID.
|
||||
|
||||
Raises:
|
||||
ValueError: If both contexts and user_contexts are provided.
|
||||
"""
|
||||
if contexts is not None and user_contexts is not None:
|
||||
raise ValueError("Cannot specify both contexts and user_contexts")
|
||||
|
||||
params: dict[str, Any] = {"functionDeclaration": function_declaration}
|
||||
|
||||
if arguments is not None:
|
||||
params["arguments"] = arguments
|
||||
if contexts is not None:
|
||||
params["contexts"] = contexts
|
||||
if user_contexts is not None:
|
||||
params["userContexts"] = user_contexts
|
||||
if sandbox is not None:
|
||||
params["sandbox"] = sandbox
|
||||
|
||||
result = self.conn.execute(command_builder("script.addPreloadScript", params))
|
||||
return result["script"]
|
||||
|
||||
def _remove_preload_script(self, script_id: str) -> None:
|
||||
"""Removes a preload script.
|
||||
|
||||
Args:
|
||||
script_id: The preload script ID to remove.
|
||||
"""
|
||||
params = {"script": script_id}
|
||||
self.conn.execute(command_builder("script.removePreloadScript", params))
|
||||
|
||||
def _disown(self, handles: list[str], target: dict) -> None:
|
||||
"""Disowns the given handles.
|
||||
|
||||
Args:
|
||||
handles: The handles to disown.
|
||||
target: The target realm or context.
|
||||
"""
|
||||
params = {
|
||||
"handles": handles,
|
||||
"target": target,
|
||||
}
|
||||
self.conn.execute(command_builder("script.disown", params))
|
||||
|
||||
def _call_function(
|
||||
self,
|
||||
function_declaration: str,
|
||||
await_promise: bool,
|
||||
target: dict,
|
||||
arguments: list[dict] | None = None,
|
||||
result_ownership: str | None = None,
|
||||
serialization_options: dict | None = None,
|
||||
this: dict | None = None,
|
||||
user_activation: bool = False,
|
||||
) -> EvaluateResult:
|
||||
"""Calls a provided function with given arguments in a given realm.
|
||||
|
||||
Args:
|
||||
function_declaration: The function declaration to call.
|
||||
await_promise: Whether to await promise resolution.
|
||||
target: The target realm or context.
|
||||
arguments: The arguments to pass to the function.
|
||||
result_ownership: The result ownership type.
|
||||
serialization_options: The serialization options.
|
||||
this: The 'this' value for the function call.
|
||||
user_activation: Whether to trigger user activation.
|
||||
|
||||
Returns:
|
||||
EvaluateResult: The result of the function call.
|
||||
"""
|
||||
params = {
|
||||
"functionDeclaration": function_declaration,
|
||||
"awaitPromise": await_promise,
|
||||
"target": target,
|
||||
"userActivation": user_activation,
|
||||
}
|
||||
|
||||
if arguments is not None:
|
||||
params["arguments"] = arguments
|
||||
if result_ownership is not None:
|
||||
params["resultOwnership"] = result_ownership
|
||||
if serialization_options is not None:
|
||||
params["serializationOptions"] = serialization_options
|
||||
if this is not None:
|
||||
params["this"] = this
|
||||
|
||||
result = self.conn.execute(command_builder("script.callFunction", params))
|
||||
return EvaluateResult.from_json(result)
|
||||
|
||||
def _evaluate(
|
||||
self,
|
||||
expression: str,
|
||||
target: dict,
|
||||
await_promise: bool,
|
||||
result_ownership: str | None = None,
|
||||
serialization_options: dict | None = None,
|
||||
user_activation: bool = False,
|
||||
) -> EvaluateResult:
|
||||
"""Evaluates a provided script in a given realm.
|
||||
|
||||
Args:
|
||||
expression: The script expression to evaluate.
|
||||
target: The target realm or context.
|
||||
await_promise: Whether to await promise resolution.
|
||||
result_ownership: The result ownership type.
|
||||
serialization_options: The serialization options.
|
||||
user_activation: Whether to trigger user activation.
|
||||
|
||||
Returns:
|
||||
EvaluateResult: The result of the script evaluation.
|
||||
"""
|
||||
params = {
|
||||
"expression": expression,
|
||||
"target": target,
|
||||
"awaitPromise": await_promise,
|
||||
"userActivation": user_activation,
|
||||
}
|
||||
|
||||
if result_ownership is not None:
|
||||
params["resultOwnership"] = result_ownership
|
||||
if serialization_options is not None:
|
||||
params["serializationOptions"] = serialization_options
|
||||
|
||||
result = self.conn.execute(command_builder("script.evaluate", params))
|
||||
return EvaluateResult.from_json(result)
|
||||
|
||||
def _get_realms(
|
||||
self,
|
||||
context: str | None = None,
|
||||
type: str | None = None,
|
||||
) -> list[RealmInfo]:
|
||||
"""Returns a list of all realms, optionally filtered.
|
||||
|
||||
Args:
|
||||
context: The browsing context ID to filter by.
|
||||
type: The realm type to filter by.
|
||||
|
||||
Returns:
|
||||
List[RealmInfo]: A list of realm information.
|
||||
"""
|
||||
params = {}
|
||||
|
||||
if context is not None:
|
||||
params["context"] = context
|
||||
if type is not None:
|
||||
params["type"] = type
|
||||
|
||||
result = self.conn.execute(command_builder("script.getRealms", params))
|
||||
return [RealmInfo.from_json(realm) for realm in result["realms"]]
|
||||
|
||||
def _subscribe_to_log_entries(self):
|
||||
if not self.log_entry_subscribed:
|
||||
session = Session(self.conn)
|
||||
self.conn.execute(session.subscribe(LogEntryAdded.event_class))
|
||||
self.log_entry_subscribed = True
|
||||
|
||||
def _unsubscribe_from_log_entries(self):
|
||||
if self.log_entry_subscribed and LogEntryAdded.event_class not in self.conn.callbacks:
|
||||
session = Session(self.conn)
|
||||
self.conn.execute(session.unsubscribe(LogEntryAdded.event_class))
|
||||
self.log_entry_subscribed = False
|
||||
|
||||
def _handle_log_entry(self, type, handler):
|
||||
def _handle_log_entry(log_entry):
|
||||
if log_entry.type_ == type:
|
||||
handler(log_entry)
|
||||
|
||||
return _handle_log_entry
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
# 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.
|
||||
|
||||
|
||||
from selenium.webdriver.common.bidi.common import command_builder
|
||||
|
||||
|
||||
class UserPromptHandlerType:
|
||||
"""Represents the behavior of the user prompt handler."""
|
||||
|
||||
ACCEPT = "accept"
|
||||
DISMISS = "dismiss"
|
||||
IGNORE = "ignore"
|
||||
|
||||
VALID_TYPES = {ACCEPT, DISMISS, IGNORE}
|
||||
|
||||
|
||||
class UserPromptHandler:
|
||||
"""Represents the configuration of the user prompt handler."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
alert: str | None = None,
|
||||
before_unload: str | None = None,
|
||||
confirm: str | None = None,
|
||||
default: str | None = None,
|
||||
file: str | None = None,
|
||||
prompt: str | None = None,
|
||||
):
|
||||
"""Initialize UserPromptHandler.
|
||||
|
||||
Args:
|
||||
alert: Handler type for alert prompts.
|
||||
before_unload: Handler type for beforeUnload prompts.
|
||||
confirm: Handler type for confirm prompts.
|
||||
default: Default handler type for all prompts.
|
||||
file: Handler type for file picker prompts.
|
||||
prompt: Handler type for prompt dialogs.
|
||||
|
||||
Raises:
|
||||
ValueError: If any handler type is not valid.
|
||||
"""
|
||||
for field_name, value in [
|
||||
("alert", alert),
|
||||
("before_unload", before_unload),
|
||||
("confirm", confirm),
|
||||
("default", default),
|
||||
("file", file),
|
||||
("prompt", prompt),
|
||||
]:
|
||||
if value is not None and value not in UserPromptHandlerType.VALID_TYPES:
|
||||
raise ValueError(
|
||||
f"Invalid {field_name} handler type: {value}. Must be one of {UserPromptHandlerType.VALID_TYPES}"
|
||||
)
|
||||
|
||||
self.alert = alert
|
||||
self.before_unload = before_unload
|
||||
self.confirm = confirm
|
||||
self.default = default
|
||||
self.file = file
|
||||
self.prompt = prompt
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
"""Convert the UserPromptHandler to a dictionary for BiDi protocol.
|
||||
|
||||
Returns:
|
||||
Dictionary representation suitable for BiDi protocol.
|
||||
"""
|
||||
field_mapping = {
|
||||
"alert": "alert",
|
||||
"before_unload": "beforeUnload",
|
||||
"confirm": "confirm",
|
||||
"default": "default",
|
||||
"file": "file",
|
||||
"prompt": "prompt",
|
||||
}
|
||||
|
||||
result = {}
|
||||
for attr_name, dict_key in field_mapping.items():
|
||||
value = getattr(self, attr_name)
|
||||
if value is not None:
|
||||
result[dict_key] = value
|
||||
return result
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, conn):
|
||||
self.conn = conn
|
||||
|
||||
def subscribe(self, *events, browsing_contexts=None):
|
||||
params = {
|
||||
"events": events,
|
||||
}
|
||||
if browsing_contexts is None:
|
||||
browsing_contexts = []
|
||||
if browsing_contexts:
|
||||
params["browsingContexts"] = browsing_contexts
|
||||
return command_builder("session.subscribe", params)
|
||||
|
||||
def unsubscribe(self, *events, browsing_contexts=None):
|
||||
params = {
|
||||
"events": events,
|
||||
}
|
||||
if browsing_contexts is None:
|
||||
browsing_contexts = []
|
||||
if browsing_contexts:
|
||||
params["browsingContexts"] = browsing_contexts
|
||||
return command_builder("session.unsubscribe", params)
|
||||
|
||||
def status(self):
|
||||
"""The session.status command returns information about the remote end's readiness.
|
||||
|
||||
Returns information about the remote end's readiness to create new sessions
|
||||
and may include implementation-specific metadata.
|
||||
|
||||
Returns:
|
||||
Dictionary containing the ready state (bool), message (str) and metadata.
|
||||
"""
|
||||
cmd = command_builder("session.status", {})
|
||||
return self.conn.execute(cmd)
|
||||
+413
@@ -0,0 +1,413 @@
|
||||
# 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.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from selenium.webdriver.common.bidi.common import command_builder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from selenium.webdriver.remote.websocket_connection import WebSocketConnection
|
||||
|
||||
|
||||
class SameSite:
|
||||
"""Represents the possible same site values for cookies."""
|
||||
|
||||
STRICT = "strict"
|
||||
LAX = "lax"
|
||||
NONE = "none"
|
||||
DEFAULT = "default"
|
||||
|
||||
|
||||
class BytesValue:
|
||||
"""Represents a bytes value."""
|
||||
|
||||
TYPE_BASE64 = "base64"
|
||||
TYPE_STRING = "string"
|
||||
|
||||
def __init__(self, type: str, value: str):
|
||||
self.type = type
|
||||
self.value = value
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
"""Converts the BytesValue to a dictionary.
|
||||
|
||||
Returns:
|
||||
A dictionary representation of the BytesValue.
|
||||
"""
|
||||
return {"type": self.type, "value": self.value}
|
||||
|
||||
|
||||
class Cookie:
|
||||
"""Represents a cookie."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
value: BytesValue,
|
||||
domain: str,
|
||||
path: str | None = None,
|
||||
size: int | None = None,
|
||||
http_only: bool | None = None,
|
||||
secure: bool | None = None,
|
||||
same_site: str | None = None,
|
||||
expiry: int | None = None,
|
||||
):
|
||||
self.name = name
|
||||
self.value = value
|
||||
self.domain = domain
|
||||
self.path = path
|
||||
self.size = size
|
||||
self.http_only = http_only
|
||||
self.secure = secure
|
||||
self.same_site = same_site
|
||||
self.expiry = expiry
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> Cookie:
|
||||
"""Creates a Cookie instance from a dictionary.
|
||||
|
||||
Args:
|
||||
data: A dictionary containing the cookie information.
|
||||
|
||||
Returns:
|
||||
A new instance of Cookie.
|
||||
"""
|
||||
# Validation for empty strings
|
||||
name = data.get("name")
|
||||
if not name:
|
||||
raise ValueError("name is required and cannot be empty")
|
||||
domain = data.get("domain")
|
||||
if not domain:
|
||||
raise ValueError("domain is required and cannot be empty")
|
||||
|
||||
value = BytesValue(data.get("value", {}).get("type"), data.get("value", {}).get("value"))
|
||||
return cls(
|
||||
name=str(name),
|
||||
value=value,
|
||||
domain=str(domain),
|
||||
path=data.get("path"),
|
||||
size=data.get("size"),
|
||||
http_only=data.get("httpOnly"),
|
||||
secure=data.get("secure"),
|
||||
same_site=data.get("sameSite"),
|
||||
expiry=data.get("expiry"),
|
||||
)
|
||||
|
||||
|
||||
class CookieFilter:
|
||||
"""Represents a filter for cookies."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str | None = None,
|
||||
value: BytesValue | None = None,
|
||||
domain: str | None = None,
|
||||
path: str | None = None,
|
||||
size: int | None = None,
|
||||
http_only: bool | None = None,
|
||||
secure: bool | None = None,
|
||||
same_site: str | None = None,
|
||||
expiry: int | None = None,
|
||||
):
|
||||
self.name = name
|
||||
self.value = value
|
||||
self.domain = domain
|
||||
self.path = path
|
||||
self.size = size
|
||||
self.http_only = http_only
|
||||
self.secure = secure
|
||||
self.same_site = same_site
|
||||
self.expiry = expiry
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Converts the CookieFilter to a dictionary.
|
||||
|
||||
Returns:
|
||||
A dictionary representation of the CookieFilter.
|
||||
"""
|
||||
result: dict[str, Any] = {}
|
||||
if self.name is not None:
|
||||
result["name"] = self.name
|
||||
if self.value is not None:
|
||||
result["value"] = self.value.to_dict()
|
||||
if self.domain is not None:
|
||||
result["domain"] = self.domain
|
||||
if self.path is not None:
|
||||
result["path"] = self.path
|
||||
if self.size is not None:
|
||||
result["size"] = self.size
|
||||
if self.http_only is not None:
|
||||
result["httpOnly"] = self.http_only
|
||||
if self.secure is not None:
|
||||
result["secure"] = self.secure
|
||||
if self.same_site is not None:
|
||||
result["sameSite"] = self.same_site
|
||||
if self.expiry is not None:
|
||||
result["expiry"] = self.expiry
|
||||
return result
|
||||
|
||||
|
||||
class PartitionKey:
|
||||
"""Represents a storage partition key."""
|
||||
|
||||
def __init__(self, user_context: str | None = None, source_origin: str | None = None):
|
||||
self.user_context = user_context
|
||||
self.source_origin = source_origin
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> PartitionKey:
|
||||
"""Creates a PartitionKey instance from a dictionary.
|
||||
|
||||
Args:
|
||||
data: A dictionary containing the partition key information.
|
||||
|
||||
Returns:
|
||||
A new instance of PartitionKey.
|
||||
"""
|
||||
return cls(
|
||||
user_context=data.get("userContext"),
|
||||
source_origin=data.get("sourceOrigin"),
|
||||
)
|
||||
|
||||
|
||||
class BrowsingContextPartitionDescriptor:
|
||||
"""Represents a browsing context partition descriptor."""
|
||||
|
||||
def __init__(self, context: str):
|
||||
self.type = "context"
|
||||
self.context = context
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
"""Converts the BrowsingContextPartitionDescriptor to a dictionary.
|
||||
|
||||
Returns:
|
||||
Dict: A dictionary representation of the BrowsingContextPartitionDescriptor.
|
||||
"""
|
||||
return {"type": self.type, "context": self.context}
|
||||
|
||||
|
||||
class StorageKeyPartitionDescriptor:
|
||||
"""Represents a storage key partition descriptor."""
|
||||
|
||||
def __init__(self, user_context: str | None = None, source_origin: str | None = None):
|
||||
self.type = "storageKey"
|
||||
self.user_context = user_context
|
||||
self.source_origin = source_origin
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
"""Converts the StorageKeyPartitionDescriptor to a dictionary.
|
||||
|
||||
Returns:
|
||||
Dict: A dictionary representation of the StorageKeyPartitionDescriptor.
|
||||
"""
|
||||
result = {"type": self.type}
|
||||
if self.user_context is not None:
|
||||
result["userContext"] = self.user_context
|
||||
if self.source_origin is not None:
|
||||
result["sourceOrigin"] = self.source_origin
|
||||
return result
|
||||
|
||||
|
||||
class PartialCookie:
|
||||
"""Represents a partial cookie for setting."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
value: BytesValue,
|
||||
domain: str,
|
||||
path: str | None = None,
|
||||
http_only: bool | None = None,
|
||||
secure: bool | None = None,
|
||||
same_site: str | None = None,
|
||||
expiry: int | None = None,
|
||||
):
|
||||
self.name = name
|
||||
self.value = value
|
||||
self.domain = domain
|
||||
self.path = path
|
||||
self.http_only = http_only
|
||||
self.secure = secure
|
||||
self.same_site = same_site
|
||||
self.expiry = expiry
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Converts the PartialCookie to a dictionary.
|
||||
|
||||
Returns:
|
||||
-------
|
||||
Dict: A dictionary representation of the PartialCookie.
|
||||
"""
|
||||
result: dict[str, Any] = {
|
||||
"name": self.name,
|
||||
"value": self.value.to_dict(),
|
||||
"domain": self.domain,
|
||||
}
|
||||
if self.path is not None:
|
||||
result["path"] = self.path
|
||||
if self.http_only is not None:
|
||||
result["httpOnly"] = self.http_only
|
||||
if self.secure is not None:
|
||||
result["secure"] = self.secure
|
||||
if self.same_site is not None:
|
||||
result["sameSite"] = self.same_site
|
||||
if self.expiry is not None:
|
||||
result["expiry"] = self.expiry
|
||||
return result
|
||||
|
||||
|
||||
class GetCookiesResult:
|
||||
"""Represents the result of a getCookies command."""
|
||||
|
||||
def __init__(self, cookies: list[Cookie], partition_key: PartitionKey):
|
||||
self.cookies = cookies
|
||||
self.partition_key = partition_key
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> GetCookiesResult:
|
||||
"""Creates a GetCookiesResult instance from a dictionary.
|
||||
|
||||
Args:
|
||||
data: A dictionary containing the get cookies result information.
|
||||
|
||||
Returns:
|
||||
A new instance of GetCookiesResult.
|
||||
"""
|
||||
cookies = [Cookie.from_dict(cookie) for cookie in data.get("cookies", [])]
|
||||
partition_key = PartitionKey.from_dict(data.get("partitionKey", {}))
|
||||
return cls(cookies=cookies, partition_key=partition_key)
|
||||
|
||||
|
||||
class SetCookieResult:
|
||||
"""Represents the result of a setCookie command."""
|
||||
|
||||
def __init__(self, partition_key: PartitionKey):
|
||||
self.partition_key = partition_key
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> SetCookieResult:
|
||||
"""Creates a SetCookieResult instance from a dictionary.
|
||||
|
||||
Args:
|
||||
data: A dictionary containing the set cookie result information.
|
||||
|
||||
Returns:
|
||||
A new instance of SetCookieResult.
|
||||
"""
|
||||
partition_key = PartitionKey.from_dict(data.get("partitionKey", {}))
|
||||
return cls(partition_key=partition_key)
|
||||
|
||||
|
||||
class DeleteCookiesResult:
|
||||
"""Represents the result of a deleteCookies command."""
|
||||
|
||||
def __init__(self, partition_key: PartitionKey):
|
||||
self.partition_key = partition_key
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> DeleteCookiesResult:
|
||||
"""Creates a DeleteCookiesResult instance from a dictionary.
|
||||
|
||||
Args:
|
||||
data: A dictionary containing the delete cookies result information.
|
||||
|
||||
Returns:
|
||||
A new instance of DeleteCookiesResult.
|
||||
"""
|
||||
partition_key = PartitionKey.from_dict(data.get("partitionKey", {}))
|
||||
return cls(partition_key=partition_key)
|
||||
|
||||
|
||||
class Storage:
|
||||
"""BiDi implementation of the storage module."""
|
||||
|
||||
def __init__(self, conn: WebSocketConnection) -> None:
|
||||
self.conn = conn
|
||||
|
||||
def get_cookies(
|
||||
self,
|
||||
filter: CookieFilter | None = None,
|
||||
partition: BrowsingContextPartitionDescriptor | StorageKeyPartitionDescriptor | None = None,
|
||||
) -> GetCookiesResult:
|
||||
"""Gets cookies matching the specified filter.
|
||||
|
||||
Args:
|
||||
filter: Optional filter to specify which cookies to retrieve.
|
||||
partition: Optional partition key to limit the scope of the operation.
|
||||
|
||||
Returns:
|
||||
A GetCookiesResult containing the cookies and partition key.
|
||||
|
||||
Example:
|
||||
result = await storage.get_cookies(
|
||||
filter=CookieFilter(name="sessionId"),
|
||||
partition=PartitionKey(...)
|
||||
)
|
||||
"""
|
||||
params = {}
|
||||
if filter is not None:
|
||||
params["filter"] = filter.to_dict()
|
||||
if partition is not None:
|
||||
params["partition"] = partition.to_dict()
|
||||
|
||||
result = self.conn.execute(command_builder("storage.getCookies", params))
|
||||
return GetCookiesResult.from_dict(result)
|
||||
|
||||
def set_cookie(
|
||||
self,
|
||||
cookie: PartialCookie,
|
||||
partition: BrowsingContextPartitionDescriptor | StorageKeyPartitionDescriptor | None = None,
|
||||
) -> SetCookieResult:
|
||||
"""Sets a cookie in the browser.
|
||||
|
||||
Args:
|
||||
cookie: The cookie to set.
|
||||
partition: Optional partition descriptor.
|
||||
|
||||
Returns:
|
||||
The result of the set cookie command.
|
||||
"""
|
||||
params = {"cookie": cookie.to_dict()}
|
||||
if partition is not None:
|
||||
params["partition"] = partition.to_dict()
|
||||
|
||||
result = self.conn.execute(command_builder("storage.setCookie", params))
|
||||
return SetCookieResult.from_dict(result)
|
||||
|
||||
def delete_cookies(
|
||||
self,
|
||||
filter: CookieFilter | None = None,
|
||||
partition: BrowsingContextPartitionDescriptor | StorageKeyPartitionDescriptor | None = None,
|
||||
) -> DeleteCookiesResult:
|
||||
"""Deletes cookies that match the given parameters.
|
||||
|
||||
Args:
|
||||
filter: Optional filter to match cookies to delete.
|
||||
partition: Optional partition descriptor.
|
||||
|
||||
Returns:
|
||||
The result of the delete cookies command.
|
||||
"""
|
||||
params = {}
|
||||
if filter is not None:
|
||||
params["filter"] = filter.to_dict()
|
||||
if partition is not None:
|
||||
params["partition"] = partition.to_dict()
|
||||
|
||||
result = self.conn.execute(command_builder("storage.deleteCookies", params))
|
||||
return DeleteCookiesResult.from_dict(result)
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
# 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.
|
||||
|
||||
|
||||
from selenium.common.exceptions import WebDriverException
|
||||
from selenium.webdriver.common.bidi.common import command_builder
|
||||
|
||||
|
||||
class WebExtension:
|
||||
"""BiDi implementation of the webExtension module."""
|
||||
|
||||
def __init__(self, conn):
|
||||
self.conn = conn
|
||||
|
||||
def install(self, path=None, archive_path=None, base64_value=None) -> dict:
|
||||
"""Installs a web extension in the remote end.
|
||||
|
||||
You must provide exactly one of the parameters.
|
||||
|
||||
Args:
|
||||
path: Path to an extension directory.
|
||||
archive_path: Path to an extension archive file.
|
||||
base64_value: Base64 encoded string of the extension archive.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the extension ID.
|
||||
"""
|
||||
if sum(x is not None for x in (path, archive_path, base64_value)) != 1:
|
||||
raise ValueError("Exactly one of path, archive_path, or base64_value must be provided")
|
||||
|
||||
if path is not None:
|
||||
extension_data = {"type": "path", "path": path}
|
||||
elif archive_path is not None:
|
||||
extension_data = {"type": "archivePath", "path": archive_path}
|
||||
elif base64_value is not None:
|
||||
extension_data = {"type": "base64", "value": base64_value}
|
||||
|
||||
params = {"extensionData": extension_data}
|
||||
|
||||
try:
|
||||
result = self.conn.execute(command_builder("webExtension.install", params))
|
||||
return result
|
||||
except WebDriverException as e:
|
||||
if "Method not available" in str(e):
|
||||
raise WebDriverException(
|
||||
f"{e!s}. If you are using Chrome or Edge, add '--enable-unsafe-extension-debugging' "
|
||||
"and '--remote-debugging-pipe' arguments or set options.enable_webextensions = True"
|
||||
) from e
|
||||
raise
|
||||
|
||||
def uninstall(self, extension_id_or_result: str | dict) -> None:
|
||||
"""Uninstalls a web extension from the remote end.
|
||||
|
||||
Args:
|
||||
extension_id_or_result: Either the extension ID as a string or the result dictionary
|
||||
from a previous install() call containing the extension ID.
|
||||
"""
|
||||
if isinstance(extension_id_or_result, dict):
|
||||
extension_id = extension_id_or_result.get("extension")
|
||||
else:
|
||||
extension_id = extension_id_or_result
|
||||
|
||||
params = {"extension": extension_id}
|
||||
self.conn.execute(command_builder("webExtension.uninstall", params))
|
||||
@@ -0,0 +1,99 @@
|
||||
# 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 By implementation."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
ByType = Literal["id", "xpath", "link text", "partial link text", "name", "tag name", "class name", "css selector"]
|
||||
|
||||
|
||||
class By:
|
||||
"""Set of supported locator strategies.
|
||||
|
||||
ID:
|
||||
--
|
||||
Select the element by its ID.
|
||||
|
||||
>>> element = driver.find_element(By.ID, "myElement")
|
||||
|
||||
XPATH:
|
||||
------
|
||||
Select the element via XPATH.
|
||||
- absolute path
|
||||
- relative path
|
||||
|
||||
>>> element = driver.find_element(By.XPATH, "//html/body/div")
|
||||
|
||||
LINK_TEXT:
|
||||
----------
|
||||
Select the link element having the exact text.
|
||||
|
||||
>>> element = driver.find_element(By.LINK_TEXT, "myLink")
|
||||
|
||||
PARTIAL_LINK_TEXT:
|
||||
------------------
|
||||
Select the link element having the partial text.
|
||||
|
||||
>>> element = driver.find_element(By.PARTIAL_LINK_TEXT, "my")
|
||||
|
||||
NAME:
|
||||
----
|
||||
Select the element by its name attribute.
|
||||
|
||||
>>> element = driver.find_element(By.NAME, "myElement")
|
||||
|
||||
TAG_NAME:
|
||||
--------
|
||||
Select the element by its tag name.
|
||||
|
||||
>>> element = driver.find_element(By.TAG_NAME, "div")
|
||||
|
||||
CLASS_NAME:
|
||||
-----------
|
||||
Select the element by its class name.
|
||||
|
||||
>>> element = driver.find_element(By.CLASS_NAME, "myElement")
|
||||
|
||||
CSS_SELECTOR:
|
||||
-------------
|
||||
Select the element by its CSS selector.
|
||||
|
||||
>>> element = driver.find_element(By.CSS_SELECTOR, "div.myElement")
|
||||
"""
|
||||
|
||||
ID: ByType = "id"
|
||||
XPATH: ByType = "xpath"
|
||||
LINK_TEXT: ByType = "link text"
|
||||
PARTIAL_LINK_TEXT: ByType = "partial link text"
|
||||
NAME: ByType = "name"
|
||||
TAG_NAME: ByType = "tag name"
|
||||
CLASS_NAME: ByType = "class name"
|
||||
CSS_SELECTOR: ByType = "css selector"
|
||||
|
||||
_custom_finders: dict[str, str] = {}
|
||||
|
||||
@classmethod
|
||||
def register_custom_finder(cls, name: str, strategy: str) -> None:
|
||||
cls._custom_finders[name] = strategy
|
||||
|
||||
@classmethod
|
||||
def get_finder(cls, name: str) -> str | None:
|
||||
return cls._custom_finders.get(name) or getattr(cls, name.upper(), None)
|
||||
|
||||
@classmethod
|
||||
def clear_custom_finders(cls) -> None:
|
||||
cls._custom_finders.clear()
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
# 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 Desired Capabilities implementation."""
|
||||
|
||||
|
||||
class DesiredCapabilities:
|
||||
"""Set of default supported desired capabilities.
|
||||
|
||||
Use this as a starting point for creating a desired capabilities object for
|
||||
requesting remote webdrivers for connecting to selenium server or selenium grid.
|
||||
|
||||
Usage Example::
|
||||
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.firefox.options import Options
|
||||
|
||||
selenium_grid_url = "http://198.0.0.1:4444/wd/hub"
|
||||
|
||||
# Create a new Options object for the desired browser.
|
||||
options = Options()
|
||||
options.set_capability("platformName", "windows")
|
||||
options.browser_version = "142"
|
||||
|
||||
# Instantiate an instance of Remote WebDriver with the new options.
|
||||
driver = webdriver.Remote(command_executor=selenium_grid_url, options=options)
|
||||
"""
|
||||
|
||||
FIREFOX = {
|
||||
"browserName": "firefox",
|
||||
"acceptInsecureCerts": True,
|
||||
"moz:debuggerAddress": True,
|
||||
}
|
||||
|
||||
INTERNETEXPLORER = {
|
||||
"browserName": "internet explorer",
|
||||
"platformName": "windows",
|
||||
}
|
||||
|
||||
EDGE = {
|
||||
"browserName": "MicrosoftEdge",
|
||||
}
|
||||
|
||||
CHROME = {
|
||||
"browserName": "chrome",
|
||||
}
|
||||
|
||||
SAFARI = {
|
||||
"browserName": "safari",
|
||||
"platformName": "mac",
|
||||
}
|
||||
|
||||
HTMLUNIT = {
|
||||
"browserName": "htmlunit",
|
||||
"version": "",
|
||||
"platform": "ANY",
|
||||
}
|
||||
|
||||
HTMLUNITWITHJS = {
|
||||
"browserName": "htmlunit",
|
||||
"version": "firefox",
|
||||
"platform": "ANY",
|
||||
"javascriptEnabled": True,
|
||||
}
|
||||
|
||||
IPHONE = {
|
||||
"browserName": "iPhone",
|
||||
"version": "",
|
||||
"platform": "mac",
|
||||
}
|
||||
|
||||
IPAD = {
|
||||
"browserName": "iPad",
|
||||
"version": "",
|
||||
"platform": "mac",
|
||||
}
|
||||
|
||||
WEBKITGTK = {
|
||||
"browserName": "MiniBrowser",
|
||||
}
|
||||
|
||||
WPEWEBKIT = {
|
||||
"browserName": "MiniBrowser",
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
from ..v147 import *
|
||||
BIN
Binary file not shown.
+59
@@ -0,0 +1,59 @@
|
||||
# DO NOT EDIT THIS FILE!
|
||||
#
|
||||
# This file is generated from the CDP specification. If you need to make
|
||||
# changes, edit the generator and regenerate all of the modules.
|
||||
from . import accessibility
|
||||
from . import animation
|
||||
from . import audits
|
||||
from . import autofill
|
||||
from . import background_service
|
||||
from . import bluetooth_emulation
|
||||
from . import browser
|
||||
from . import css
|
||||
from . import cache_storage
|
||||
from . import cast
|
||||
from . import console
|
||||
from . import dom
|
||||
from . import dom_debugger
|
||||
from . import dom_snapshot
|
||||
from . import dom_storage
|
||||
from . import debugger
|
||||
from . import device_access
|
||||
from . import device_orientation
|
||||
from . import emulation
|
||||
from . import event_breakpoints
|
||||
from . import extensions
|
||||
from . import fed_cm
|
||||
from . import fetch
|
||||
from . import file_system
|
||||
from . import headless_experimental
|
||||
from . import heap_profiler
|
||||
from . import io
|
||||
from . import indexed_db
|
||||
from . import input_
|
||||
from . import inspector
|
||||
from . import layer_tree
|
||||
from . import log
|
||||
from . import media
|
||||
from . import memory
|
||||
from . import network
|
||||
from . import overlay
|
||||
from . import pwa
|
||||
from . import page
|
||||
from . import performance
|
||||
from . import performance_timeline
|
||||
from . import preload
|
||||
from . import profiler
|
||||
from . import runtime
|
||||
from . import schema
|
||||
from . import security
|
||||
from . import service_worker
|
||||
from . import storage
|
||||
from . import system_info
|
||||
from . import target
|
||||
from . import tethering
|
||||
from . import tracing
|
||||
from . import web_audio
|
||||
from . import web_authn
|
||||
from . import util
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user