Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
seleniumhq
GitHub Repository: seleniumhq/selenium
Path: blob/trunk/py/private/_network_handlers.py
11809 views
1
# Licensed to the Software Freedom Conservancy (SFC) under one
2
# or more contributor license agreements. See the NOTICE file
3
# distributed with this work for additional information
4
# regarding copyright ownership. The SFC licenses this file
5
# to you under the Apache License, Version 2.0 (the
6
# "License"); you may not use this file except in compliance
7
# with the License. You may obtain a copy of the License at
8
#
9
# http://www.apache.org/licenses/LICENSE-2.0
10
#
11
# Unless required by applicable law or agreed to in writing,
12
# software distributed under the License is distributed on an
13
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
# KIND, either express or implied. See the License for the
15
# specific language governing permissions and limitations
16
# under the License.
17
18
"""High-level request/response interception helpers for the WebDriver BiDi network module.
19
20
This module is copied verbatim into the generated ``selenium.webdriver.common.bidi``
21
package by Bazel (see ``create-bidi-src`` in ``py/BUILD.bazel``). The generated
22
``network`` module re-exports :class:`Request` and :class:`Response` and
23
instantiates the handler registries, which layer a user-friendly handler API on
24
top of the CDDL-generated low-level commands (``network.addIntercept``,
25
``network.continueRequest``, ``network.continueResponse``,
26
``network.failRequest``, ``network.provideResponse``).
27
28
Handlers registered through :meth:`RequestHandlerRegistry.add_handler` receive a
29
:class:`Request` and may observe it, mutate it, fail it, or stub a response.
30
After every matching handler has run, the registry reconciles the recorded
31
outcome and issues exactly one BiDi command per request:
32
33
1. If any handler called :meth:`Request.fail`, the request is failed.
34
2. Else if any handler called :meth:`Request.provide_response`, the stubbed
35
response is provided.
36
3. Else if any handler mutated the request, it is continued with the mutations.
37
4. Otherwise the request is continued unmodified.
38
39
Handlers registered through :meth:`ResponseHandlerRegistry.add_handler` receive
40
a :class:`Response` at the ``responseStarted`` phase and may observe or mutate
41
it. Reconciliation works the same way: a mutated body requires
42
``network.provideResponse`` (the wire protocol cannot continue a response with
43
a new body), other mutations are applied via ``network.continueResponse``, and
44
untouched responses are continued unmodified.
45
46
Handlers registered through :meth:`AuthHandlerRegistry.add_handler` receive an
47
:class:`AuthenticationRequest` at the ``authRequired`` phase and may call
48
:meth:`AuthenticationRequest.provide_credentials` or
49
:meth:`AuthenticationRequest.cancel`. Reconciliation issues exactly one
50
``network.continueWithAuth`` command per challenge: ``cancel`` takes precedence
51
over provided credentials, and if no handler responded the challenge is
52
continued with action ``default`` so the browser's own behavior (usually the
53
authentication prompt) applies.
54
55
Extra headers registered through :meth:`RequestHandlerRegistry.set_extra_header`
56
are merged into every subsequent request. BiDi has no dedicated command for
57
this, so the registry pauses each request at ``beforeRequestSent`` with a
58
match-everything intercept and merges the headers while reconciling — the same
59
single continue cycle that applies user handler mutations.
60
61
This mirrors the reconciliation rules in the cross-binding BiDi API design and
62
means purely observational handlers never stall the page.
63
"""
64
65
from __future__ import annotations
66
67
import logging
68
import re
69
from collections.abc import Callable
70
from typing import Any
71
72
from selenium.webdriver.common.bidi.common import command_builder
73
74
logger = logging.getLogger(__name__)
75
76
# Event names accepted by the legacy phase-based add_request_handler API.
77
LEGACY_REQUEST_HANDLER_EVENTS = ("auth_required", "before_request", "before_request_sent")
78
79
80
def looks_like_url_glob(value: Any) -> bool:
81
"""Heuristically distinguish a URL glob from a legacy event name.
82
83
URL globs contain wildcard or URL punctuation (``* ? / : .``); bare
84
word-like strings are assumed to be (possibly misspelled) event names so
85
the legacy API can reject them with a helpful error.
86
"""
87
return isinstance(value, str) and any(char in value for char in "*?/:.")
88
89
90
def _decode_bytes_value(value: Any) -> Any:
91
"""Decode a BiDi BytesValue dict to a plain string where possible."""
92
if isinstance(value, dict) and value.get("type") == "string":
93
return value.get("value")
94
return value
95
96
97
def _encode_bytes_value(value: Any) -> Any:
98
"""Encode a plain string as a BiDi BytesValue dict; pass dicts through."""
99
if isinstance(value, str):
100
return {"type": "string", "value": value}
101
if hasattr(value, "to_bidi_dict"):
102
return value.to_bidi_dict()
103
return value
104
105
106
def headers_to_dict(headers: list | None) -> dict[str, str]:
107
"""Convert a BiDi header list to a name → value mapping."""
108
result: dict[str, str] = {}
109
for header in headers or []:
110
if isinstance(header, dict):
111
result[header.get("name")] = _decode_bytes_value(header.get("value"))
112
return result
113
114
115
def dict_to_headers(headers: dict[str, Any] | None) -> list[dict]:
116
"""Convert a name → value mapping to a BiDi header list."""
117
return [{"name": name, "value": _encode_bytes_value(value)} for name, value in (headers or {}).items()]
118
119
120
def cookies_to_list(cookies: list | None) -> list[dict]:
121
"""Convert BiDi request cookies to plain dicts with decoded values."""
122
result = []
123
for cookie in cookies or []:
124
if isinstance(cookie, dict):
125
decoded = dict(cookie)
126
decoded["value"] = _decode_bytes_value(cookie.get("value"))
127
result.append(decoded)
128
return result
129
130
131
def list_to_cookie_headers(cookies: list | None) -> list[dict]:
132
"""Convert plain cookie dicts to BiDi CookieHeader entries."""
133
result = []
134
for cookie in cookies or []:
135
if hasattr(cookie, "to_bidi_dict"):
136
result.append(cookie.to_bidi_dict())
137
elif isinstance(cookie, dict):
138
result.append({"name": cookie.get("name"), "value": _encode_bytes_value(cookie.get("value"))})
139
return result
140
141
142
# Optional network.SetCookieHeader fields, accepting both snake_case (Python
143
# style) and camelCase (wire style) keys from user-supplied cookie dicts.
144
_SET_COOKIE_FIELD_ALIASES = {
145
"domain": "domain",
146
"expiry": "expiry",
147
"http_only": "httpOnly",
148
"httpOnly": "httpOnly",
149
"max_age": "maxAge",
150
"maxAge": "maxAge",
151
"path": "path",
152
"same_site": "sameSite",
153
"sameSite": "sameSite",
154
"secure": "secure",
155
}
156
157
158
def list_to_set_cookie_headers(cookies: list | None) -> list[dict]:
159
"""Convert plain cookie dicts to BiDi SetCookieHeader entries."""
160
result = []
161
for cookie in cookies or []:
162
if hasattr(cookie, "to_bidi_dict"):
163
result.append(cookie.to_bidi_dict())
164
elif isinstance(cookie, dict):
165
entry = {"name": cookie.get("name"), "value": _encode_bytes_value(cookie.get("value"))}
166
for key, wire_key in _SET_COOKIE_FIELD_ALIASES.items():
167
if cookie.get(key) is not None:
168
entry[wire_key] = cookie[key]
169
result.append(entry)
170
return result
171
172
173
def glob_to_regex(pattern: str) -> re.Pattern:
174
"""Compile a URL glob (``*``, ``**``, ``?``) into a regular expression.
175
176
``*`` matches within a path segment, ``**`` matches across segments, and
177
``?`` matches a single character. Matching is anchored at both ends.
178
"""
179
parts: list[str] = []
180
i = 0
181
while i < len(pattern):
182
char = pattern[i]
183
if char == "*":
184
if pattern[i : i + 2] == "**":
185
parts.append(".*")
186
i += 2
187
else:
188
parts.append("[^/]*")
189
i += 1
190
elif char == "?":
191
parts.append("[^/]")
192
i += 1
193
else:
194
parts.append(re.escape(char))
195
i += 1
196
return re.compile("".join(parts) + r"\Z")
197
198
199
def _literal_component(component: str) -> str | None:
200
"""Return the component when it is literal, ``None`` when it has wildcards.
201
202
``UrlPatternPattern`` properties match literally and browsers reject
203
wildcard characters in them ("Forbidden characters"), while omitted
204
properties match anything — so wildcard-bearing components are omitted
205
from the browser-side filter and Python-side glob matching narrows the
206
results.
207
"""
208
if not component or "*" in component or "?" in component:
209
return None
210
return component
211
212
213
def glob_to_url_pattern(pattern: str) -> dict | None:
214
"""Translate a URL glob into a BiDi ``network.UrlPatternPattern`` dict.
215
216
Only the literal components of the glob are translated; components
217
containing wildcards are omitted (omitted UrlPatternPattern properties
218
match anything), so the browser-side filter may be broader than the glob
219
and callers must still apply Python-side matching. Returns ``{}`` when
220
no browser-side filter can be derived (match everything) and ``None``
221
when the glob is not a URL-shaped pattern.
222
"""
223
if pattern in ("*", "**"):
224
return {}
225
if "://" not in pattern:
226
return None
227
scheme, _, rest = pattern.partition("://")
228
host, slash, path = rest.partition("/")
229
port = None
230
if ":" in host:
231
host, _, port = host.partition(":")
232
result: dict[str, Any] = {"type": "pattern"}
233
if _literal_component(scheme):
234
result["protocol"] = scheme
235
if _literal_component(host):
236
result["hostname"] = host
237
if port and _literal_component(port):
238
result["port"] = port
239
if slash and _literal_component("/" + path):
240
result["pathname"] = "/" + path
241
if len(result) == 1:
242
return {}
243
return result
244
245
246
def globs_to_url_patterns(patterns: list | None) -> list[dict] | None:
247
"""Translate URL globs into BiDi UrlPatterns for ``network.addIntercept``.
248
249
Returns ``None`` when no browser-side filtering should be applied (match
250
everything, or at least one glob is untranslatable). Raw dict patterns are
251
passed through unchanged so callers can supply wire-level UrlPatterns.
252
"""
253
if not patterns:
254
return None
255
translated = []
256
for pattern in patterns:
257
if isinstance(pattern, dict):
258
translated.append(pattern)
259
continue
260
url_pattern = glob_to_url_pattern(pattern)
261
if url_pattern is None or url_pattern == {}:
262
return None
263
translated.append(url_pattern)
264
return translated or None
265
266
267
class Request:
268
"""Wraps a BiDi network request event and provides request action methods.
269
270
Attributes:
271
url: The request URL.
272
method: The HTTP method (e.g. ``"GET"``).
273
headers: The request headers as a name → value dict.
274
cookies: The request cookies as a list of dicts.
275
body: The request body. BiDi does not expose the outgoing body at the
276
``beforeRequestSent`` phase, so this is ``None`` unless mutated.
277
resource_type: The resource destination (e.g. ``"script"``, ``"image"``)
278
when reported by the browser.
279
"""
280
281
def __init__(self, conn, params, deferred: bool = False):
282
self._conn = conn
283
self._params = params if isinstance(params, dict) else {}
284
req = self._params.get("request", {}) or {}
285
self.url = req.get("url", "")
286
self._request_id = req.get("request")
287
self.method = req.get("method")
288
self.headers = headers_to_dict(req.get("headers"))
289
self.cookies = cookies_to_list(req.get("cookies"))
290
self.body = None
291
self.resource_type = req.get("destination") or req.get("initiatorType")
292
# Deferred requests record actions for later reconciliation by the
293
# registry; non-deferred (legacy) requests execute actions immediately.
294
self._deferred = deferred
295
self._handled = False
296
self._failed = False
297
self._stub: dict | None = None
298
self._mutations: dict[str, Any] = {}
299
300
def set_url(self, url: str) -> None:
301
"""Change the request URL before it is continued."""
302
self.url = url
303
self._mutations["url"] = url
304
305
def set_method(self, method: str) -> None:
306
"""Change the HTTP method before the request is continued."""
307
self.method = method
308
self._mutations["method"] = method
309
310
def set_headers(self, headers: dict[str, Any]) -> None:
311
"""Replace the request headers before the request is continued."""
312
self.headers = dict(headers)
313
self._mutations["headers"] = self.headers
314
315
def set_cookies(self, cookies: list) -> None:
316
"""Replace the request cookies before the request is continued."""
317
self.cookies = list(cookies)
318
self._mutations["cookies"] = self.cookies
319
320
def set_body(self, body: str) -> None:
321
"""Set the request body before the request is continued."""
322
self.body = body
323
self._mutations["body"] = body
324
325
def fail(self) -> None:
326
"""Fail the request.
327
328
Takes precedence over stubbed responses and mutations when multiple
329
handlers act on the same request.
330
"""
331
if self._deferred:
332
self._failed = True
333
else:
334
self._execute_fail()
335
336
def provide_response(self, status=None, headers=None, body=None, reason_phrase=None) -> None:
337
"""Respond to the request with a stubbed response.
338
339
Args:
340
status: HTTP status code for the stubbed response.
341
headers: Response headers as a name → value dict.
342
body: Response body string.
343
reason_phrase: Optional HTTP reason phrase.
344
"""
345
stub = {
346
"status": status,
347
"headers": headers,
348
"body": body,
349
"reason_phrase": reason_phrase,
350
}
351
if self._deferred:
352
if self._stub is None:
353
self._stub = stub
354
else:
355
self._stub = stub
356
self._execute_provide_response()
357
358
def continue_request(
359
self,
360
*,
361
url: str | None = None,
362
method: str | None = None,
363
headers: dict[str, Any] | None = None,
364
cookies: list | None = None,
365
body: str | None = None,
366
) -> None:
367
"""Continue the intercepted request, applying any recorded mutations.
368
369
Each keyword argument overrides the corresponding mutation recorded via
370
``set_url``/``set_method``/``set_headers``/``set_cookies``/``set_body``.
371
Arguments use the same Python types as those setters and are translated
372
to the BiDi wire format automatically. Data URLs (``data:``) are
373
skipped silently because browsers do not create an interceptable request
374
entry for them, so calling ``network.continueRequest`` would raise
375
"no such request".
376
377
Args:
378
url: Replacement request URL.
379
method: Replacement HTTP method.
380
headers: Replacement request headers as a name → value dict.
381
cookies: Replacement request cookies as a list of dicts.
382
body: Replacement request body string.
383
"""
384
self._handled = True
385
if self.url.startswith("data:"):
386
return
387
overrides = {"url": url, "method": method, "headers": headers, "cookies": cookies, "body": body}
388
params = self._continue_params({k: v for k, v in overrides.items() if v is not None})
389
self._conn.execute(command_builder("network.continueRequest", params))
390
391
def _continue_params(self, overrides: dict | None = None) -> dict:
392
params: dict[str, Any] = {"request": self._request_id}
393
mutations = {**self._mutations, **(overrides or {})}
394
if "url" in mutations:
395
params["url"] = mutations["url"]
396
if "method" in mutations:
397
params["method"] = mutations["method"]
398
if "headers" in mutations:
399
params["headers"] = dict_to_headers(mutations["headers"])
400
if "cookies" in mutations:
401
params["cookies"] = list_to_cookie_headers(mutations["cookies"])
402
if "body" in mutations:
403
params["body"] = _encode_bytes_value(mutations["body"])
404
return params
405
406
def _execute_fail(self) -> None:
407
self._handled = True
408
if self.url.startswith("data:"):
409
return
410
self._conn.execute(command_builder("network.failRequest", {"request": self._request_id}))
411
412
def _execute_provide_response(self) -> None:
413
self._handled = True
414
if self.url.startswith("data:"):
415
return
416
stub = self._stub or {}
417
params: dict[str, Any] = {"request": self._request_id}
418
if stub.get("status") is not None:
419
params["statusCode"] = stub["status"]
420
if stub.get("reason_phrase") is not None:
421
params["reasonPhrase"] = stub["reason_phrase"]
422
if stub.get("headers") is not None:
423
params["headers"] = dict_to_headers(stub["headers"])
424
if stub.get("body") is not None:
425
params["body"] = _encode_bytes_value(stub["body"])
426
self._conn.execute(command_builder("network.provideResponse", params))
427
428
def _resolve(self) -> None:
429
"""Reconcile recorded handler actions into a single BiDi command."""
430
if self._handled:
431
return
432
if self._failed:
433
self._execute_fail()
434
elif self._stub is not None:
435
self._execute_provide_response()
436
else:
437
self.continue_request()
438
439
440
class Response:
441
"""Wraps a BiDi ``network.responseStarted`` event and provides response action methods.
442
443
Attributes:
444
url: The response URL.
445
status: The HTTP status code.
446
reason_phrase: The HTTP status text reported by the browser.
447
headers: The response headers as a name → value dict.
448
mime_type: The response MIME type when reported by the browser.
449
cookies: Cookies to set on the response. BiDi does not expose parsed
450
response cookies at the ``responseStarted`` phase, so this is empty
451
unless mutated via :meth:`set_cookies`.
452
body: The response body. BiDi does not expose the body at the
453
``responseStarted`` phase, so this is ``None`` unless mutated via
454
:meth:`set_body`.
455
"""
456
457
def __init__(self, conn, params, deferred: bool = False):
458
self._conn = conn
459
self._params = params if isinstance(params, dict) else {}
460
req = self._params.get("request", {}) or {}
461
resp = self._params.get("response", {}) or {}
462
self._request_id = req.get("request")
463
self.url = resp.get("url") or req.get("url", "")
464
self.status = resp.get("status")
465
self.reason_phrase = resp.get("statusText")
466
self.headers = headers_to_dict(resp.get("headers"))
467
self.mime_type = resp.get("mimeType")
468
self.cookies: list = []
469
self.body = None
470
# Deferred responses record actions for later reconciliation by the
471
# registry; non-deferred responses execute actions immediately.
472
self._deferred = deferred
473
self._handled = False
474
self._mutations: dict[str, Any] = {}
475
476
def set_status(self, status: int, reason_phrase: str | None = None) -> None:
477
"""Change the response status code (and optionally the reason phrase)."""
478
self.status = status
479
self._mutations["status"] = status
480
if reason_phrase is not None:
481
self.reason_phrase = reason_phrase
482
self._mutations["reason_phrase"] = reason_phrase
483
484
def set_headers(self, headers: dict[str, Any]) -> None:
485
"""Replace the response headers before the response is continued."""
486
self.headers = dict(headers)
487
self._mutations["headers"] = self.headers
488
489
def set_cookies(self, cookies: list) -> None:
490
"""Replace the cookies set by the response before it is continued."""
491
self.cookies = list(cookies)
492
self._mutations["cookies"] = self.cookies
493
494
def set_body(self, body: str) -> None:
495
"""Replace the response body.
496
497
The wire protocol cannot continue a response with a new body, so a
498
body mutation is reconciled via ``network.provideResponse``, carrying
499
over the (possibly mutated) status and headers.
500
"""
501
self.body = body
502
self._mutations["body"] = body
503
504
def continue_response(
505
self,
506
*,
507
status: int | None = None,
508
reason_phrase: str | None = None,
509
headers: dict[str, Any] | None = None,
510
cookies: list | None = None,
511
) -> None:
512
"""Continue the intercepted response, applying any recorded mutations.
513
514
Each keyword argument overrides the corresponding mutation recorded via
515
``set_status``/``set_headers``/``set_cookies``. Arguments use the same
516
Python types as those setters and are translated to the BiDi wire format
517
automatically. Data URLs (``data:``) are skipped silently because
518
browsers do not create an interceptable entry for them.
519
520
Args:
521
status: Replacement HTTP status code.
522
reason_phrase: Replacement HTTP reason phrase.
523
headers: Replacement response headers as a name → value dict.
524
cookies: Replacement set-cookie entries as a list of dicts.
525
"""
526
self._handled = True
527
if self.url.startswith("data:"):
528
return
529
overrides = {"status": status, "reason_phrase": reason_phrase, "headers": headers, "cookies": cookies}
530
params = self._continue_params({k: v for k, v in overrides.items() if v is not None})
531
self._conn.execute(command_builder("network.continueResponse", params))
532
533
def _continue_params(self, overrides: dict | None = None) -> dict:
534
params: dict[str, Any] = {"request": self._request_id}
535
mutations = {**self._mutations, **(overrides or {})}
536
if "status" in mutations:
537
params["statusCode"] = mutations["status"]
538
if "reason_phrase" in mutations:
539
params["reasonPhrase"] = mutations["reason_phrase"]
540
if "headers" in mutations:
541
params["headers"] = dict_to_headers(mutations["headers"])
542
if "cookies" in mutations:
543
params["cookies"] = list_to_set_cookie_headers(mutations["cookies"])
544
return params
545
546
def _execute_provide_response(self) -> None:
547
self._handled = True
548
if self.url.startswith("data:"):
549
return
550
# provideResponse replaces the whole response, so carry over the
551
# current (possibly mutated) status and headers alongside the body.
552
params: dict[str, Any] = {"request": self._request_id}
553
if self.status is not None:
554
params["statusCode"] = self.status
555
if self.reason_phrase:
556
params["reasonPhrase"] = self.reason_phrase
557
if self.headers:
558
params["headers"] = dict_to_headers(self.headers)
559
if "cookies" in self._mutations:
560
params["cookies"] = list_to_set_cookie_headers(self._mutations["cookies"])
561
if self.body is not None:
562
params["body"] = _encode_bytes_value(self.body)
563
self._conn.execute(command_builder("network.provideResponse", params))
564
565
def _resolve(self) -> None:
566
"""Reconcile recorded handler actions into a single BiDi command."""
567
if self._handled:
568
return
569
if "body" in self._mutations:
570
try:
571
self._execute_provide_response()
572
except Exception:
573
# Some browsers cannot replace a body at the responseStarted
574
# phase; continue with the remaining mutations rather than
575
# leaving the response blocked and stalling the page.
576
logger.exception("provideResponse failed; continuing response without the body mutation")
577
self._handled = False
578
self.continue_response()
579
else:
580
self.continue_response()
581
582
583
class AuthenticationRequest:
584
"""Wraps a BiDi ``network.authRequired`` event and provides auth action methods.
585
586
Attributes:
587
url: The URL of the request that triggered the challenge.
588
realm: The authentication realm of the first challenge, when reported.
589
scheme: The authentication scheme (e.g. ``"basic"``) of the first
590
challenge, when reported.
591
challenges: Every challenge as a list of ``{"scheme", "realm"}`` dicts.
592
"""
593
594
def __init__(self, conn, params, deferred: bool = False):
595
self._conn = conn
596
self._params = params if isinstance(params, dict) else {}
597
req = self._params.get("request", {}) or {}
598
resp = self._params.get("response", {}) or {}
599
self._request_id = req.get("request")
600
self.url = resp.get("url") or req.get("url", "")
601
self.challenges = [challenge for challenge in resp.get("authChallenges") or [] if isinstance(challenge, dict)]
602
first = self.challenges[0] if self.challenges else {}
603
self.realm = first.get("realm")
604
self.scheme = first.get("scheme")
605
# Deferred challenges record actions for later reconciliation by the
606
# registry; non-deferred challenges execute actions immediately.
607
self._deferred = deferred
608
self._handled = False
609
self._cancelled = False
610
self._credentials: dict | None = None
611
612
def provide_credentials(self, username: str, password: str) -> None:
613
"""Respond to the challenge with the given credentials.
614
615
When multiple handlers act on the same challenge the first provided
616
credentials win, and a ``cancel()`` from any handler takes precedence.
617
"""
618
credentials = {"type": "password", "username": username, "password": password}
619
if self._deferred:
620
if self._credentials is None:
621
self._credentials = credentials
622
else:
623
self._credentials = credentials
624
self._execute_continue("provideCredentials")
625
626
def cancel(self) -> None:
627
"""Cancel the challenge, failing the request with an auth error.
628
629
Takes precedence over provided credentials when multiple handlers act
630
on the same challenge.
631
"""
632
if self._deferred:
633
self._cancelled = True
634
else:
635
self._execute_continue("cancel")
636
637
def _execute_continue(self, action: str) -> None:
638
self._handled = True
639
params: dict[str, Any] = {"request": self._request_id, "action": action}
640
if action == "provideCredentials":
641
params["credentials"] = self._credentials
642
self._conn.execute(command_builder("network.continueWithAuth", params))
643
644
def _resolve(self) -> None:
645
"""Reconcile recorded handler actions into a single BiDi command."""
646
if self._handled:
647
return
648
if self._cancelled:
649
self._execute_continue("cancel")
650
elif self._credentials is not None:
651
self._execute_continue("provideCredentials")
652
else:
653
self._execute_continue("default")
654
655
656
class _HandlerEntry:
657
"""A registered handler with its patterns and intercept."""
658
659
def __init__(self, handler_id: str, patterns: list | None, callback: Callable, intercept_id: str | None):
660
self.handler_id = handler_id
661
self.callback = callback
662
self.intercept_id = intercept_id
663
self._regexes = [glob_to_regex(p) for p in patterns or [] if isinstance(p, str)]
664
665
def matches(self, url: str) -> bool:
666
if not self._regexes:
667
return True
668
return any(regex.match(url) for regex in self._regexes)
669
670
671
class _BaseHandlerRegistry:
672
"""Tracks high-level handlers for one intercept phase and reconciles outcomes.
673
674
One event subscription dispatches each event to all matching handlers,
675
then reconciles the request or response exactly once. Each handler gets
676
its own browser-side intercept so removal restores prior behavior.
677
"""
678
679
# Subclasses configure the intercept phase, the subscription event key,
680
# the handler-ID prefix and the wrapper class handed to callbacks.
681
_phase: str
682
_event_name: str
683
_id_prefix: str
684
_label: str
685
686
def __init__(self, network):
687
self._network = network
688
self._handlers: dict[str, _HandlerEntry] = {}
689
self._subscription_callback_id: int | None = None
690
self._counter = 0
691
692
def _wrap(self, params):
693
raise NotImplementedError
694
695
def add_handler(self, url_patterns, callback: Callable) -> str:
696
"""Register a handler; returns a handler ID for later removal."""
697
if isinstance(url_patterns, str):
698
url_patterns = [url_patterns]
699
patterns = list(url_patterns) if url_patterns else None
700
bidi_patterns = globs_to_url_patterns(patterns)
701
intercept_result = self._network._add_intercept(phases=[self._phase], url_patterns=bidi_patterns)
702
intercept_id = intercept_result.get("intercept") if intercept_result else None
703
if self._subscription_callback_id is None:
704
self._subscription_callback_id = self._network.add_event_handler(self._event_name, self._on_event)
705
self._counter += 1
706
handler_id = f"{self._id_prefix}-{self._counter}"
707
self._handlers[handler_id] = _HandlerEntry(handler_id, patterns, callback, intercept_id)
708
logger.debug("Added %s %s (patterns=%s)", self._label, handler_id, patterns)
709
return handler_id
710
711
def remove_handler(self, handler_id: str) -> None:
712
"""Remove a handler and its intercept by handler ID."""
713
entry = self._handlers.pop(handler_id, None)
714
if entry is None:
715
raise ValueError(f"{self._label.capitalize()} '{handler_id}' not found")
716
if entry.intercept_id:
717
self._network._remove_intercept(entry.intercept_id)
718
if not self._keep_subscription() and self._subscription_callback_id is not None:
719
self._network.remove_event_handler(self._event_name, self._subscription_callback_id)
720
self._subscription_callback_id = None
721
logger.debug("Removed %s %s", self._label, handler_id)
722
723
def clear(self) -> None:
724
"""Remove all registered handlers and their intercepts."""
725
for handler_id in list(self._handlers):
726
self.remove_handler(handler_id)
727
728
def intercept_ids(self) -> set:
729
"""Intercept IDs owned by this registry's handlers."""
730
return {entry.intercept_id for entry in self._handlers.values() if entry.intercept_id}
731
732
def _keep_subscription(self) -> bool:
733
"""Whether the event subscription is still needed."""
734
return bool(self._handlers)
735
736
def resubscribe(self) -> None:
737
"""Re-establish the event subscription after an external event-handler clear."""
738
if self._keep_subscription():
739
self._subscription_callback_id = self._network.add_event_handler(self._event_name, self._on_event)
740
else:
741
self._subscription_callback_id = None
742
743
def _before_resolve(self, wrapped) -> None:
744
"""Hook run after the handlers and before reconciliation."""
745
746
def _on_event(self, params) -> None:
747
if not isinstance(params, dict):
748
return
749
wrapped = self._wrap(params)
750
for entry in list(self._handlers.values()):
751
if not entry.matches(wrapped.url):
752
continue
753
try:
754
entry.callback(wrapped)
755
except Exception:
756
logger.exception("%s %s raised; continuing processing", self._label.capitalize(), entry.handler_id)
757
if not params.get("isBlocked"):
758
return
759
# Only reconcile requests paused by one of our intercepts; requests
760
# blocked by other subsystems (e.g. legacy handlers) are theirs to
761
# continue.
762
blocking_intercepts = set(params.get("intercepts") or [])
763
if self.intercept_ids() & blocking_intercepts:
764
self._before_resolve(wrapped)
765
wrapped._resolve()
766
767
768
class RequestHandlerRegistry(_BaseHandlerRegistry):
769
"""Dispatches ``network.beforeRequestSent`` events to request handlers.
770
771
Also owns the extra-headers store: BiDi has no dedicated set-extra-headers
772
command, so while any extra header is set every request is paused by a
773
dedicated match-everything intercept and continued with the merged
774
headers during reconciliation. Sharing the registry's subscription and
775
reconciliation means a request paused by both the extra-headers intercept
776
and user handlers is still continued exactly once.
777
"""
778
779
_phase = "beforeRequestSent"
780
_event_name = "before_request"
781
_id_prefix = "request-handler"
782
_label = "request handler"
783
784
def __init__(self, network):
785
super().__init__(network)
786
# Header names are case-insensitive per HTTP, so keys are lowercased.
787
self.extra_headers: dict[str, Any] = {}
788
self._extra_headers_intercept: str | None = None
789
790
def _wrap(self, params):
791
return Request(self._network._conn, params, deferred=True)
792
793
def set_extra_header(self, name: str, value: str) -> None:
794
"""Record a header to merge into every subsequent request."""
795
self.extra_headers[name.lower()] = value
796
if self._extra_headers_intercept is None:
797
result = self._network._add_intercept(phases=[self._phase])
798
self._extra_headers_intercept = result.get("intercept") if result else None
799
if self._subscription_callback_id is None:
800
self._subscription_callback_id = self._network.add_event_handler(self._event_name, self._on_event)
801
logger.debug("Added extra header %s", name.lower())
802
803
def remove_extra_header(self, name: str) -> None:
804
"""Stop merging a header by (case-insensitive) name."""
805
if self.extra_headers.pop(name.lower(), None) is None:
806
raise ValueError(f"Extra header '{name}' not found")
807
if not self.extra_headers:
808
self._drop_extra_headers_intercept()
809
logger.debug("Removed extra header %s", name.lower())
810
811
def clear_extra_headers(self) -> None:
812
"""Stop merging all extra headers."""
813
self.extra_headers.clear()
814
self._drop_extra_headers_intercept()
815
816
def _drop_extra_headers_intercept(self) -> None:
817
if self._extra_headers_intercept:
818
self._network._remove_intercept(self._extra_headers_intercept)
819
self._extra_headers_intercept = None
820
if not self._keep_subscription() and self._subscription_callback_id is not None:
821
self._network.remove_event_handler(self._event_name, self._subscription_callback_id)
822
self._subscription_callback_id = None
823
824
def intercept_ids(self) -> set:
825
ids = super().intercept_ids()
826
if self._extra_headers_intercept:
827
ids.add(self._extra_headers_intercept)
828
return ids
829
830
def _keep_subscription(self) -> bool:
831
return bool(self._handlers or self.extra_headers)
832
833
def _before_resolve(self, request) -> None:
834
"""Merge extra headers into requests about to be continued.
835
836
Failed and stubbed requests never reach the wire and manually
837
continued requests have already been sent, so only the
838
plain-continue path is merged.
839
"""
840
if not self.extra_headers:
841
return
842
if request._handled or request._failed or request._stub is not None:
843
return
844
merged = {name: value for name, value in request.headers.items() if name.lower() not in self.extra_headers}
845
merged.update(self.extra_headers)
846
request.set_headers(merged)
847
848
849
class ResponseHandlerRegistry(_BaseHandlerRegistry):
850
"""Dispatches ``network.responseStarted`` events to response handlers."""
851
852
_phase = "responseStarted"
853
_event_name = "response_started"
854
_id_prefix = "response-handler"
855
_label = "response handler"
856
857
def _wrap(self, params):
858
return Response(self._network._conn, params, deferred=True)
859
860
861
class AuthHandlerRegistry(_BaseHandlerRegistry):
862
"""Dispatches ``network.authRequired`` events to authentication handlers."""
863
864
_phase = "authRequired"
865
_event_name = "auth_required"
866
_id_prefix = "auth-handler"
867
_label = "authentication handler"
868
869
def _wrap(self, params):
870
return AuthenticationRequest(self._network._conn, params, deferred=True)
871
872