Skip to content

[py] BiDi Network implementation of Intercepts and Auth in Python #14592

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 106 commits into from
Apr 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
106 commits
Select commit Hold shift + click to select a range
4cde788
added network.py, updated webdriver.py, added bidi_network_tests.py
shbenzer Oct 12, 2024
14bf971
Tests are passing
shbenzer Oct 13, 2024
32d309d
Merge branch 'trunk' into network_implementation_python
shbenzer Oct 13, 2024
a83ff21
Removed redundant bazel test
shbenzer Oct 14, 2024
bfe622b
Merge branch 'trunk' into network_implementation_python
shbenzer Oct 16, 2024
1072df0
Merge branch 'trunk' into network_implementation_python
shbenzer Oct 18, 2024
981a1db
deleted unused leftover function
shbenzer Oct 18, 2024
1c7dc79
deleting other leftover function
shbenzer Oct 18, 2024
739ec81
removed unused imports
shbenzer Oct 18, 2024
5284ad4
cleanup
shbenzer Oct 18, 2024
01f6aaf
fixed url_for() - tests passing
shbenzer Oct 18, 2024
17e7099
Merge branch 'trunk' into network_implementation_python
shbenzer Oct 22, 2024
2325788
added instantiation of self._network = None
shbenzer Oct 22, 2024
26379a0
Merge branch 'trunk' into network_implementation_python
shbenzer Oct 22, 2024
8bc75c3
Made functions async
shbenzer Oct 22, 2024
090e471
Update network.py
shbenzer Oct 23, 2024
3cb5762
linting
shbenzer Oct 23, 2024
e397d89
linting
shbenzer Oct 23, 2024
004ef85
linting
shbenzer Oct 23, 2024
c57b56c
remove debugging port from fixture
shbenzer Oct 23, 2024
a657a21
Merge branch 'trunk' into network_implementation_python
shbenzer Oct 31, 2024
6fa65a9
Merge branch 'trunk' into network_implementation_python
shbenzer Nov 14, 2024
ccb0956
Merge branch 'trunk' into network_implementation_python
shbenzer Nov 14, 2024
edcc64e
Removed Async/Await
shbenzer Nov 14, 2024
ebb8e28
Made changes per review
shbenzer Nov 14, 2024
340ec5c
added back linting
shbenzer Nov 14, 2024
36aa5cb
Added intial high-level api implementation with basic docstring for u…
shbenzer Nov 14, 2024
9643780
extended the tests
shbenzer Nov 14, 2024
9441cc8
added docstrings to all functions
shbenzer Nov 14, 2024
ecb4b9d
Merge branch 'trunk' into network_implementation_python
shbenzer Nov 14, 2024
d92cf21
abstracted out response/request... rewrote tests
shbenzer Nov 14, 2024
6edb859
Update network.py
shbenzer Nov 14, 2024
f0beab8
refactored high-level api implementation
shbenzer Nov 15, 2024
aa49ee1
minor fix
shbenzer Nov 15, 2024
f342d66
Merge branch 'trunk' into network_implementation_python
shbenzer Nov 15, 2024
5b519a7
Merge branch 'trunk' into network_implementation_python
shbenzer Nov 15, 2024
6b8b17a
New adjustments per @p0deje
shbenzer Nov 15, 2024
610bece
Merge branch 'trunk' into network_implementation_python
shbenzer Nov 15, 2024
d061117
Merge branch 'trunk' into network_implementation_python
shbenzer Nov 15, 2024
4b494a2
Merge branch 'trunk' into network_implementation_python
shbenzer Nov 16, 2024
7c3849d
Updated assert text on failure
shbenzer Nov 16, 2024
c17308d
Added xfails for safari
shbenzer Nov 17, 2024
537f71c
fixed tests
shbenzer Nov 17, 2024
c7d930a
Merge branch 'trunk' into network_implementation_python
shbenzer Nov 17, 2024
ab64ac0
Update bidi_network_tests.py
shbenzer Nov 17, 2024
3570254
linting
shbenzer Nov 18, 2024
84a5df2
added back commands
shbenzer Nov 18, 2024
d10a364
linting
shbenzer Nov 18, 2024
86ad50e
linting
shbenzer Nov 18, 2024
4b44735
nonlocalized request_id
shbenzer Nov 18, 2024
85fb7e7
Merge branch 'trunk' into network_implementation_python
shbenzer Nov 18, 2024
0d80501
Merge branch 'trunk' into network_implementation_python
shbenzer Nov 18, 2024
6bc315d
replaced fixture with driver.network
shbenzer Nov 18, 2024
9673a69
updated webdriver.py to intiailize websocket if none
shbenzer Nov 18, 2024
fc80258
updated tests
shbenzer Nov 18, 2024
5eea5d0
added license
shbenzer Nov 18, 2024
28eca14
update serialize and network
shbenzer Nov 18, 2024
26170fb
Update websocket_connection.py
shbenzer Nov 18, 2024
45f1cda
Update network.py
shbenzer Nov 18, 2024
4a32ff7
got network to intialize and browser to recognize commands, issue wit…
shbenzer Nov 18, 2024
e924236
Merge branch 'trunk' into network_implementation_python
shbenzer Nov 18, 2024
59dec9f
Merge branch 'trunk' into network_implementation_python
shbenzer Nov 22, 2024
cbcce93
Merge branch 'trunk' into network_implementation_python
diemol Nov 25, 2024
f717186
Merge branch 'trunk' into network_implementation_python
diemol Nov 25, 2024
aac479f
Merge branch 'trunk' into network_implementation_python
shbenzer Dec 20, 2024
35e7ac3
don't need to explicitly return there
shbenzer Dec 21, 2024
46ef051
switched to using add_callback
shbenzer Dec 22, 2024
475e877
Merge branch 'trunk' into network_implementation_python
shbenzer Dec 22, 2024
1e9dc81
unified callback_id and request/response_id
shbenzer Dec 23, 2024
622c61e
Merge branch 'trunk' into network_implementation_python
shbenzer Dec 24, 2024
29a5af9
removed wasted memort
shbenzer Dec 24, 2024
b7be10d
removed nonlocal usage
shbenzer Dec 24, 2024
2b3bb2a
Merge branch 'trunk' into network_implementation_python
shbenzer Dec 27, 2024
1f035d2
Merge branch 'trunk' into network_implementation_python
shbenzer Jan 16, 2025
ea577f5
added import, gave abstractions command_iterator class, added .to_json()
shbenzer Feb 12, 2025
994fe95
added session_subscribe() to __on()
shbenzer Feb 12, 2025
5c5c0aa
shift ooo
shbenzer Feb 12, 2025
d53dc25
format.sh
shbenzer Feb 12, 2025
8e79940
spinning wheel of death
shbenzer Feb 12, 2025
eed84c6
Merge branch 'trunk' into network_implementation_python
shbenzer Feb 12, 2025
ceb6468
Merge branch 'trunk' into network_implementation_python
shbenzer Feb 12, 2025
1ac681e
removed unused import
shbenzer Feb 12, 2025
1e1c8d1
Added more comprehensive docstrings ... minor tweaks
shbenzer Feb 17, 2025
d9fe883
formatting
shbenzer Feb 17, 2025
631a456
Update command.py
shbenzer Feb 21, 2025
09f4fa4
command.py
shbenzer Feb 22, 2025
0af40b7
Complete rewrite - all tests passing but auth on google.com
shbenzer Mar 26, 2025
5a0f29a
linting
shbenzer Mar 26, 2025
f31a883
remove unused command.py commands
shbenzer Mar 26, 2025
a5da929
removed commands
shbenzer Mar 26, 2025
e1af59e
Removed unused Response functionality - refined tests. All that fails…
shbenzer Mar 26, 2025
b1c45b0
minor tweak
shbenzer Mar 26, 2025
bde022a
Updated auth test - was trying to find page that onlt exists in rb bi…
shbenzer Mar 26, 2025
110da30
updated event_name for continue, still failing
shbenzer Mar 26, 2025
493326f
linting
shbenzer Mar 26, 2025
515ab58
uncommented
shbenzer Mar 26, 2025
00f76a5
refactored continue_with_auth to proceed with ContinueWithNoAuthCrede…
shbenzer Mar 26, 2025
8a85a1b
updated continue_request, moved _continue_with_auth under Request obj…
shbenzer Mar 26, 2025
30ffaba
typo
shbenzer Mar 27, 2025
a0a8790
Merge branch 'SeleniumHQ:trunk' into network_implementation_python
shbenzer Mar 29, 2025
901dd04
made changes to websocket_connection.py, added fail_request()
shbenzer Apr 4, 2025
b1299c8
Update network.py
shbenzer Apr 4, 2025
6f60704
marked add/remove_intercept() private, added clear_request_handlers()
shbenzer Apr 4, 2025
5b75c6d
marked xfail for chromium bidi issues
shbenzer Apr 4, 2025
a166557
added remove_auth_handler, all tests passing
shbenzer Apr 4, 2025
1f1acc3
prevented editing iterator during iteration
shbenzer Apr 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
361 changes: 361 additions & 0 deletions py/selenium/webdriver/common/bidi/network.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,361 @@
# 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 NetworkEvent:
"""Represents a network event."""

def __init__(self, event_class, **kwargs):
self.event_class = event_class
self.params = kwargs

@classmethod
def from_json(cls, json):
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):
self.conn = conn
self.intercepts = []
self.callbacks = {}
self.subscriptions = {}

def command_builder(self, method, params):
"""Build a command iterator to send to the network.

Parameters:
----------
method (str): The method to execute.
params (dict): The parameters to pass to the method.
"""
command = {"method": method, "params": params}
cmd = yield command
return cmd

def _add_intercept(self, phases=[], contexts=None, url_patterns=None):
"""Add an intercept to the network.

Parameters:
----------
phases (list, optional): A list of phases to intercept.
Default is empty list.
contexts (list, optional): A list of contexts to intercept.
Default is None.
url_patterns (list, optional): A list of URL patterns to intercept.
Default is None.

Returns:
-------
str : intercept id
"""
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 = self.command_builder("network.addIntercept", params)

result = self.conn.execute(cmd)
self.intercepts.append(result["intercept"])
return result

def _remove_intercept(self, intercept=None):
"""Remove a specific intercept, or all intercepts.

Parameters:
----------
intercept (str, optional): The intercept to remove.
Default is None.

Raises:
------
Exception: If intercept is not found.

Notes:
-----
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(self.command_builder("network.removeIntercept", {"intercept": intercept_id}))
self.intercepts.remove(intercept_id)
else:
try:
self.conn.execute(self.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, callback):
"""Set a callback function to subscribe to a network event.

Parameters:
----------
event_name (str): The event to subscribe to.
callback (function): The callback function to execute on event.
Takes Request object as argument.

Returns:
-------
int : callback id
"""

event = NetworkEvent(event_name)

def _callback(event_data):
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_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 = 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, callback, url_patterns=None, contexts=None):
"""Add a request handler to the network.

Parameters:
----------
event (str): The event to subscribe to.
url_patterns (list, optional): A list of URL patterns to intercept.
Default is None.
contexts (list, optional): A list of contexts to intercept.
Default is None.
callback (function): The callback function to execute on request interception
Takes Request object as argument.

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 = {}
params["events"] = [event_name]
self.conn.execute(self.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, callback_id):
"""Remove a request handler from the network.

Parameters:
----------
event_name (str): The event to unsubscribe from.
callback_id (int): 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 = {}
params["events"] = [event_name]
self.conn.execute(self.command_builder("session.unsubscribe", params))
del self.subscriptions[event_name]

def clear_request_handlers(self):
"""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 = {}
params["events"] = [event_name]
self.conn.execute(self.command_builder("session.unsubscribe", params))
self.subscriptions = {}

def add_auth_handler(self, username, password):
"""Add an authentication handler to the network.

Parameters:
----------
username (str): The username to authenticate with.
password (str): The password to authenticate with.

Returns:
-------
int : callback id
"""
event = "auth_required"

def _callback(request):
request._continue_with_auth(username, password)

return self.add_request_handler(event, _callback)

def remove_auth_handler(self, callback_id):
"""Remove an authentication handler from the network.

Parameters:
----------
callback_id (int): 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,
body_size=None,
cookies=None,
resource_type=None,
headers=None,
headers_size=None,
method=None,
timings=None,
url=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 command_builder(self, method, params):
"""Build a command iterator to send to the network.

Parameters:
----------
method (str): The method to execute.
params (dict): The parameters to pass to the method.
"""
command = {"method": method, "params": params}
cmd = yield command
return cmd

def fail_request(self):
"""Fail this request."""

if not self.request_id:
raise ValueError("Request not found.")

params = {"request": self.request_id}
self.network.conn.execute(self.command_builder("network.failRequest", params))

def continue_request(self, body=None, method=None, headers=None, cookies=None, url=None):
"""Continue after intercepting this request."""

if not self.request_id:
raise ValueError("Request not found.")

params = {"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(self.command_builder("network.continueRequest", params))

def _continue_with_auth(self, username=None, password=None):
"""Continue with authentication.

Parameters:
----------
request (Request): The request to continue with.
username (str): The username to authenticate with.
password (str): The password to authenticate with.

Notes:
-----
If username or password is None, it attempts auth with no credentials
"""

params = {}
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(self.command_builder("network.continueWithAuth", params))
12 changes: 12 additions & 0 deletions py/selenium/webdriver/remote/webdriver.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from selenium.common.exceptions import NoSuchCookieException
from selenium.common.exceptions import NoSuchElementException
from selenium.common.exceptions import WebDriverException
from selenium.webdriver.common.bidi.network import Network
from selenium.webdriver.common.bidi.script import Script
from selenium.webdriver.common.by import By
from selenium.webdriver.common.options import ArgOptions
Expand Down Expand Up @@ -252,6 +253,7 @@ def __init__(

self._websocket_connection = None
self._script = None
self._network = None

def __repr__(self):
return f'<{type(self).__module__}.{type(self).__name__} (session="{self.session_id}")>'
Expand Down Expand Up @@ -1257,6 +1259,16 @@ def _start_bidi(self):

self._websocket_connection = WebSocketConnection(ws_url)

@property
def network(self):
if not self._websocket_connection:
self._start_bidi()

if not hasattr(self, "_network") or self._network is None:
self._network = Network(self._websocket_connection)

return self._network

def _get_cdp_details(self):
import json

Expand Down
Loading
Loading