initial commit

This commit is contained in:
2026-06-25 21:29:21 +00:00
commit 0d0a7456de
2738 changed files with 542622 additions and 0 deletions
@@ -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.
@@ -0,0 +1,187 @@
# 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 base64
import os
from typing import BinaryIO
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
from selenium.webdriver.common.options import ArgOptions
class ChromiumOptions(ArgOptions):
KEY = "goog:chromeOptions"
def __init__(self) -> None:
"""Initialize ChromiumOptions with default settings."""
super().__init__()
self._binary_location: str = ""
self._extension_files: list[str] = []
self._extensions: list[str] = []
self._experimental_options: dict[str, str | int | dict | list[str]] = {}
self._debugger_address: str | None = None
self._enable_webextensions: bool = False
@property
def binary_location(self) -> str:
"""Returns the location of the binary, otherwise an empty string."""
return self._binary_location
@binary_location.setter
def binary_location(self, value: str) -> None:
"""Allows you to set where the chromium binary lives.
Args:
value: Path to the Chromium binary.
"""
if not isinstance(value, str):
raise TypeError(self.BINARY_LOCATION_ERROR)
self._binary_location = value
@property
def debugger_address(self) -> str | None:
"""Returns the address of the remote devtools instance."""
return self._debugger_address
@debugger_address.setter
def debugger_address(self, value: str) -> None:
"""Set the address of the remote devtools instance for active wait connection.
Args:
value: Address of remote devtools instance if any (hostname[:port]).
"""
if not isinstance(value, str):
raise TypeError("Debugger Address must be a string")
self._debugger_address = value
@property
def extensions(self) -> list[str]:
"""Returns a list of encoded extensions that will be loaded."""
def _decode(file_data: BinaryIO) -> str:
# Should not use base64.encodestring() which inserts newlines every
# 76 characters (per RFC 1521). Chromedriver has to remove those
# unnecessary newlines before decoding, causing performance hit.
return base64.b64encode(file_data.read()).decode("utf-8")
encoded_extensions = []
for extension in self._extension_files:
with open(extension, "rb") as f:
encoded_extensions.append(_decode(f))
return encoded_extensions + self._extensions
def add_extension(self, extension: str) -> None:
"""Add the path to an extension to be extracted to ChromeDriver.
Args:
extension: Path to the *.crx file.
"""
if extension:
extension_to_add = os.path.abspath(os.path.expanduser(extension))
if os.path.exists(extension_to_add):
self._extension_files.append(extension_to_add)
else:
raise OSError("Path to the extension doesn't exist")
else:
raise ValueError("argument can not be null")
def add_encoded_extension(self, extension: str) -> None:
"""Add Base64-encoded string with extension data to be extracted to ChromeDriver.
Args:
extension: Base64 encoded string with extension data.
"""
if extension:
self._extensions.append(extension)
else:
raise ValueError("argument can not be null")
@property
def experimental_options(self) -> dict:
"""Returns a dictionary of experimental options for chromium."""
return self._experimental_options
def add_experimental_option(self, name: str, value: str | int | dict | list[str]) -> None:
"""Adds an experimental option which is passed to chromium.
Args:
name: The experimental option name.
value: The option value.
"""
self._experimental_options[name] = value
@property
def enable_webextensions(self) -> bool:
"""Return whether webextension support is enabled for Chromium-based browsers."""
return self._enable_webextensions
@enable_webextensions.setter
def enable_webextensions(self, value: bool) -> None:
"""Enables or disables webextension support for Chromium-based browsers.
Args:
value: True to enable webextension support, False to disable.
Notes:
- When enabled, this automatically adds the required Chromium flags:
- --enable-unsafe-extension-debugging
- --remote-debugging-pipe
- When disabled, this removes BOTH flags listed above, even if they were manually added via add_argument()
before enabling webextensions.
- Enabling --remote-debugging-pipe makes the connection b/w chromedriver
and the browser use a pipe instead of a port, disabling many CDP functionalities
like devtools
"""
self._enable_webextensions = value
if value:
# Add required flags for Chromium webextension support
required_flags = ["--enable-unsafe-extension-debugging", "--remote-debugging-pipe"]
for flag in required_flags:
if flag not in self._arguments:
self.add_argument(flag)
else:
# Remove webextension flags if disabling
flags_to_remove = ["--enable-unsafe-extension-debugging", "--remote-debugging-pipe"]
for flag in flags_to_remove:
if flag in self._arguments:
self._arguments.remove(flag)
def to_capabilities(self) -> dict:
"""Creates a capabilities with all the options that have been set.
Returns:
A dictionary with all set options.
"""
caps = self._caps
chrome_options = self.experimental_options.copy()
if self.mobile_options:
chrome_options.update(self.mobile_options)
chrome_options["extensions"] = self.extensions
if self.binary_location:
chrome_options["binary"] = self.binary_location
chrome_options["args"] = self._arguments
if self.debugger_address:
chrome_options["debuggerAddress"] = self.debugger_address
caps[self.KEY] = chrome_options
return caps
@property
def default_capabilities(self) -> dict:
return DesiredCapabilities.CHROME.copy()
@@ -0,0 +1,59 @@
# 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.remote.client_config import ClientConfig
from selenium.webdriver.remote.remote_connection import RemoteConnection
class ChromiumRemoteConnection(RemoteConnection):
def __init__(
self,
remote_server_addr: str,
vendor_prefix: str,
browser_name: str,
keep_alive: bool = True,
ignore_proxy: bool = False,
client_config: ClientConfig | None = None,
) -> None:
client_config = client_config or ClientConfig(
remote_server_addr=remote_server_addr, keep_alive=keep_alive, timeout=120
)
super().__init__(
ignore_proxy=ignore_proxy,
client_config=client_config,
)
self.browser_name = browser_name
commands = self._remote_commands(vendor_prefix)
for key, value in commands.items():
self._commands[key] = value
def _remote_commands(self, vendor_prefix):
remote_commands = {
"launchApp": ("POST", "/session/$sessionId/chromium/launch_app"),
"setPermissions": ("POST", "/session/$sessionId/permissions"),
"setNetworkConditions": ("POST", "/session/$sessionId/chromium/network_conditions"),
"getNetworkConditions": ("GET", "/session/$sessionId/chromium/network_conditions"),
"deleteNetworkConditions": ("DELETE", "/session/$sessionId/chromium/network_conditions"),
"executeCdpCommand": ("POST", f"/session/$sessionId/{vendor_prefix}/cdp/execute"),
"getSinks": ("GET", f"/session/$sessionId/{vendor_prefix}/cast/get_sinks"),
"getIssueMessage": ("GET", f"/session/$sessionId/{vendor_prefix}/cast/get_issue_message"),
"setSinkToUse": ("POST", f"/session/$sessionId/{vendor_prefix}/cast/set_sink_to_use"),
"startDesktopMirroring": ("POST", f"/session/$sessionId/{vendor_prefix}/cast/start_desktop_mirroring"),
"startTabMirroring": ("POST", f"/session/$sessionId/{vendor_prefix}/cast/start_tab_mirroring"),
"stopCasting": ("POST", f"/session/$sessionId/{vendor_prefix}/cast/stop_casting"),
}
return remote_commands
@@ -0,0 +1,94 @@
# 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 logging
import os
import sys
from collections.abc import Mapping, Sequence
from typing import IO, Any
from selenium.webdriver.common import service
class ChromiumService(service.Service):
"""Service class responsible for starting and stopping the ChromiumDriver WebDriver instance.
Args:
executable_path: (Optional) Install path of the executable.
port: (Optional) Port for the service to run on, defaults to 0 where the operating system will decide.
service_args: (Optional) Sequence of args to be passed to the subprocess when launching the executable.
log_output: (Optional) int representation of STDOUT/DEVNULL, any IO instance or String path to file.
env: (Optional) Mapping of environment variables for the new process, defaults to `os.environ`.
driver_path_env_key: (Optional) Environment variable to use to get the path to the driver executable.
"""
def __init__(
self,
executable_path: str | None = None,
port: int = 0,
service_args: Sequence[str] | None = None,
log_output: int | str | IO[Any] | None = None,
env: Mapping[str, str] | None = None,
driver_path_env_key: str | None = None,
**kwargs,
) -> None:
self._service_args = list(service_args or [])
driver_path_env_key = driver_path_env_key or "SE_CHROMEDRIVER"
if isinstance(log_output, str):
self._service_args.append(f"--log-path={log_output}")
self.log_output = None
else:
self.log_output = log_output
if os.environ.get("SE_DEBUG"):
has_arg_conflicts = any(x in arg for arg in self._service_args for x in ("log-level", "log-path", "silent"))
has_output_conflict = self.log_output is not None
if has_arg_conflicts or has_output_conflict:
logging.getLogger(__name__).warning(
"Environment Variable `SE_DEBUG` is set; "
"forcing ChromiumDriver --verbose and overriding log-level/log-output/silent settings."
)
if has_arg_conflicts:
self._service_args = [
arg for arg in self._service_args if not any(x in arg for x in ("log-level", "log-path", "silent"))
]
self._service_args.append("--verbose")
self.log_output = sys.stderr
super().__init__(
executable_path=executable_path,
port=port,
env=env,
log_output=self.log_output,
driver_path_env_key=driver_path_env_key,
**kwargs,
)
def command_line_args(self) -> list[str]:
return [f"--port={self.port}"] + self._service_args
@property
def service_args(self) -> Sequence[str]:
"""Returns the sequence of service arguments."""
return self._service_args
@service_args.setter
def service_args(self, value: Sequence[str]):
if isinstance(value, str) or not isinstance(value, Sequence):
raise TypeError("service_args must be a sequence")
self._service_args = list(value)
@@ -0,0 +1,205 @@
# 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.chromium.options import ChromiumOptions
from selenium.webdriver.chromium.remote_connection import ChromiumRemoteConnection
from selenium.webdriver.chromium.service import ChromiumService
from selenium.webdriver.common.driver_finder import DriverFinder
from selenium.webdriver.common.webdriver import LocalWebDriver
from selenium.webdriver.remote.command import Command
class ChromiumDriver(LocalWebDriver):
"""Control the WebDriver instance of ChromiumDriver and drive the browser."""
def __init__(
self,
browser_name: str,
vendor_prefix: str,
options: ChromiumOptions | None = None,
service: ChromiumService | None = None,
keep_alive: bool = True,
) -> None:
"""Create a new WebDriver instance, start the service, and create new ChromiumDriver instance.
Args:
browser_name: Browser name used when matching capabilities.
vendor_prefix: Company prefix to apply to vendor-specific WebDriver extension commands.
options: Instance of ChromiumOptions.
service: Service object for handling the browser driver if you need to pass extra details.
keep_alive: Whether to configure ChromiumRemoteConnection to use HTTP keep-alive.
"""
self.service = service if service else ChromiumService()
self.options = options if options else ChromiumOptions()
finder = DriverFinder(self.service, self.options)
if finder.get_browser_path():
self.options.binary_location = finder.get_browser_path()
self.options.browser_version = None
self.service.path = self.service.env_path() or finder.get_driver_path()
self.service.start()
executor = ChromiumRemoteConnection(
remote_server_addr=self.service.service_url,
browser_name=browser_name,
vendor_prefix=vendor_prefix,
keep_alive=keep_alive,
ignore_proxy=self.options._ignore_local_proxy,
)
try:
super().__init__(command_executor=executor, options=self.options)
except Exception:
self.quit()
raise
def launch_app(self, id):
"""Launches Chromium app specified by id.
Args:
id: The id of the Chromium app to launch.
"""
return self.execute("launchApp", {"id": id})
def get_network_conditions(self):
"""Gets Chromium network emulation settings.
Returns:
A dict. For example: {'latency': 4, 'download_throughput': 2, 'upload_throughput': 2}
"""
return self.execute("getNetworkConditions")["value"]
def set_network_conditions(self, **network_conditions) -> None:
"""Sets Chromium network emulation settings.
Args:
**network_conditions: A dict with conditions specification.
Example:
driver.set_network_conditions(
offline=False,
latency=5, # additional latency (ms)
download_throughput=500 * 1024, # maximal throughput
upload_throughput=500 * 1024,
) # maximal throughput
Note: `throughput` can be used to set both (for download and upload).
"""
self.execute("setNetworkConditions", {"network_conditions": network_conditions})
def delete_network_conditions(self) -> None:
"""Resets Chromium network emulation settings."""
self.execute("deleteNetworkConditions")
def set_permissions(self, name: str, value: str) -> None:
"""Sets Applicable Permission.
Args:
name: The item to set the permission on.
value: The value to set on the item
Example:
driver.set_permissions("clipboard-read", "denied")
"""
self.execute("setPermissions", {"descriptor": {"name": name}, "state": value})
def execute_cdp_cmd(self, cmd: str, cmd_args: dict):
"""Execute Chrome Devtools Protocol command and get returned result.
The command and command args should follow chrome devtools protocol domains/commands
See:
- https://chromedevtools.github.io/devtools-protocol/
Args:
cmd: A str, command name
cmd_args: A dict, command args. empty dict {} if there is no command args
Example:
`driver.execute_cdp_cmd('Network.getResponseBody', {'requestId': requestId})`
Returns:
A dict, empty dict {} if there is no result to return.
For example to getResponseBody:
{'base64Encoded': False, 'body': 'response body string'}
"""
return super().execute_cdp_cmd(cmd, cmd_args)
def get_sinks(self) -> list:
"""Get a list of sinks available for Cast."""
return self.execute("getSinks")["value"]
def get_issue_message(self):
"""Returns an error message when there is any issue in a Cast session."""
return self.execute("getIssueMessage")["value"]
@property
def log_types(self):
"""Gets a list of the available log types.
Example:
--------
>>> driver.log_types
"""
return self.execute(Command.GET_AVAILABLE_LOG_TYPES)["value"]
def get_log(self, log_type):
"""Gets the log for a given log type.
Args:
log_type: Type of log that which will be returned
Example:
>>> driver.get_log("browser")
>>> driver.get_log("driver")
>>> driver.get_log("client")
>>> driver.get_log("server")
"""
return self.execute(Command.GET_LOG, {"type": log_type})["value"]
def set_sink_to_use(self, sink_name: str) -> dict:
"""Set a specific sink as a Cast session receiver target.
Args:
sink_name: Name of the sink to use as the target.
"""
return self.execute("setSinkToUse", {"sinkName": sink_name})
def start_desktop_mirroring(self, sink_name: str) -> dict:
"""Starts a desktop mirroring session on a specific receiver target.
Args:
sink_name: Name of the sink to use as the target.
"""
return self.execute("startDesktopMirroring", {"sinkName": sink_name})
def start_tab_mirroring(self, sink_name: str) -> dict:
"""Starts a tab mirroring session on a specific receiver target.
Args:
sink_name: Name of the sink to use as the target.
"""
return self.execute("startTabMirroring", {"sinkName": sink_name})
def stop_casting(self, sink_name: str) -> dict:
"""Stops the existing Cast session on a specific receiver target.
Args:
sink_name: Name of the sink to stop the Cast session.
"""
return self.execute("stopCasting", {"sinkName": sink_name})