Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
SeleniumHQ
GitHub Repository: SeleniumHQ/Selenium
Path: blob/trunk/py/private/bidi_enhancements_manifest.py
10192 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
19
"""
20
Enhancement manifest for BiDi code generation.
21
22
This file defines custom enhancements applied to generated BiDi modules,
23
including custom dataclass methods, parameter validation/transformation,
24
response deserialization, and field extraction.
25
26
All code must be compatible with Python 3.10+.
27
"""
28
29
from __future__ import annotations
30
31
from typing import Any
32
33
# ============================================================================
34
# Format Guide
35
# ============================================================================
36
# Each module in ENHANCEMENTS specifies enhancement rules for methods:
37
#
38
# 'module_name': {
39
# 'method_name': {
40
# 'dataclass_methods': { # For dataclass enhancements
41
# 'ClassName': ['method1', 'method2', ...]
42
# },
43
# 'preprocess': { # Pre-processing on parameters
44
# 'param_name': 'check_serialize_method'
45
# },
46
# 'deserialize': { # Deserialize response to typed objects
47
# 'response_field': 'TypeName',
48
# },
49
# 'extract_field': str, # Extract nested field from response
50
# 'extract_property': str, # Extract property from extracted items
51
# 'validate': str, # Validation function name
52
# 'transform': str, # Transformation function name
53
# }
54
# }
55
# ============================================================================
56
57
ENHANCEMENTS: dict[str, dict[str, Any]] = {
58
"browser": {
59
# Dataclass custom methods
60
"__dataclass_methods__": {
61
"ClientWindowInfo": [
62
"get_client_window",
63
"get_state",
64
"get_width",
65
"get_height",
66
"is_active",
67
"get_x",
68
"get_y",
69
],
70
},
71
# Method enhancements
72
"create_user_context": {
73
"preprocess": {
74
"proxy": "check_serialize_method",
75
"unhandled_prompt_behavior": "check_serialize_method",
76
},
77
"extract_field": "userContext",
78
},
79
"get_client_windows": {
80
"deserialize": {
81
"clientWindows": "ClientWindowInfo",
82
},
83
},
84
"get_user_contexts": {
85
"extract_field": "userContexts",
86
"extract_property": "userContext",
87
},
88
"set_download_behavior": {
89
"params_override": {
90
"allowed": "bool",
91
"destination_folder": "str",
92
"userContexts": "[*browser.UserContext]",
93
},
94
"validate": "validate_download_behavior",
95
"transform": {
96
"allowed": "allowed",
97
"destination_folder": "destination_folder",
98
"func": "transform_download_params",
99
"result_param": "download_behavior",
100
},
101
},
102
# Replace the auto-generated ClientWindowNamedState so we can add the
103
# convenience NORMAL constant. In the BiDi spec "normal" is the state
104
# represented by ClientWindowRectState, but exposing it here keeps the
105
# Python API consistent with the old ClientWindowState enum.
106
"exclude_types": ["ClientWindowNamedState", "SetClientWindowStateParameters"],
107
"extra_dataclasses": [
108
'''class ClientWindowNamedState:
109
"""Named states for a browser client window."""
110
111
FULLSCREEN = "fullscreen"
112
MAXIMIZED = "maximized"
113
MINIMIZED = "minimized"
114
NORMAL = "normal"''',
115
'''@dataclass
116
class SetClientWindowStateParameters:
117
"""SetClientWindowStateParameters.
118
119
The ``state`` field is required and must be either a named-state string
120
(e.g. ``ClientWindowNamedState.MAXIMIZED``) or a
121
:class:`ClientWindowRectState` instance. ``client_window`` is the ID of
122
the window to affect.
123
"""
124
125
client_window: Any | None = None
126
state: Any | None = None''',
127
],
128
# Override the generator-produced set_download_behavior so that
129
# downloadBehavior is never stripped by the generic None filter.
130
# The BiDi spec marks it as required (can be null, but must be present).
131
"extra_methods": [
132
''' def set_download_behavior(
133
self,
134
allowed: bool | None = None,
135
destination_folder: str | None = None,
136
user_contexts: list[Any] | None = None,
137
):
138
"""Set the download behavior for the browser.
139
140
Args:
141
allowed: ``True`` to allow downloads, ``False`` to deny, or ``None``
142
to reset to browser default (sends ``null`` to the protocol).
143
destination_folder: Destination folder for downloads. Required when
144
``allowed=True``. Accepts a string or :class:`pathlib.Path`.
145
user_contexts: Optional list of user context IDs.
146
147
Raises:
148
ValueError: If *allowed* is ``True`` and *destination_folder* is
149
omitted, or ``False`` and *destination_folder* is provided.
150
"""
151
validate_download_behavior(
152
allowed=allowed,
153
destination_folder=destination_folder,
154
user_contexts=user_contexts,
155
)
156
download_behavior = transform_download_params(allowed, destination_folder)
157
# downloadBehavior is a REQUIRED field in the BiDi spec (can be null but
158
# must be present). Do NOT use a generic None-filter on it.
159
params: dict = {"downloadBehavior": download_behavior}
160
if user_contexts is not None:
161
params["userContexts"] = user_contexts
162
cmd = command_builder("browser.setDownloadBehavior", params)
163
return self._conn.execute(cmd)''',
164
''' def set_client_window_state(
165
self,
166
client_window: Any | None = None,
167
state: Any | None = None,
168
):
169
"""Set the client window state.
170
171
Args:
172
client_window: The client window ID to apply the state to.
173
state: The window state to set. Can be one of:
174
- A string: "fullscreen", "maximized", "minimized", "normal"
175
- A ClientWindowRectState object with width, height, x, y
176
- A dict representing the state
177
178
Raises:
179
ValueError: If client_window is not provided or state is invalid.
180
"""
181
if client_window is None:
182
raise ValueError("client_window is required")
183
if state is None:
184
raise ValueError("state is required")
185
186
# Serialize ClientWindowRectState if needed
187
state_param = state
188
if hasattr(state, '__dataclass_fields__'):
189
# It's a dataclass, convert to dict
190
state_param = {
191
k: v for k, v in state.__dict__.items()
192
if v is not None
193
}
194
195
params = {
196
"clientWindow": client_window,
197
"state": state_param,
198
}
199
cmd = command_builder("browser.setClientWindowState", params)
200
return self._conn.execute(cmd)''',
201
],
202
},
203
"browsingContext": {
204
# Method enhancements
205
"exclude_methods": ["set_viewport"],
206
"create": {
207
"extract_field": "context",
208
},
209
"get_tree": {
210
"extract_field": "contexts",
211
"deserialize": {
212
"contexts": "Info",
213
},
214
},
215
"capture_screenshot": {
216
"extract_field": "data",
217
"params_override": {
218
"context": "str",
219
"format": "ImageFormat",
220
"clip": "BoxClipRectangle",
221
"origin": "str",
222
},
223
},
224
"print": {
225
"extract_field": "data",
226
},
227
"locate_nodes": {
228
"extract_field": "nodes",
229
"params_override": {
230
"context": "str",
231
"locator": "dict",
232
"serializationOptions": "dict",
233
"startNodes": "list",
234
"maxNodeCount": "int",
235
},
236
},
237
"set_viewport": {
238
"params_override": {
239
"context": "str",
240
"viewport": "dict",
241
"userContexts": "list",
242
"devicePixelRatio": "float",
243
},
244
},
245
"extra_methods": [
246
''' def set_viewport(
247
self,
248
context: str | None = None,
249
viewport: Any = ...,
250
user_contexts: Any | None = None,
251
device_pixel_ratio: Any = ...,
252
):
253
"""Execute browsingContext.setViewport.
254
255
Uses sentinel defaults so explicit None is serialized for viewport/devicePixelRatio,
256
while omitted arguments are not sent.
257
"""
258
params = {}
259
if context is not None:
260
params["context"] = context
261
if user_contexts is not None:
262
params["userContexts"] = user_contexts
263
if viewport is not ...:
264
params["viewport"] = viewport
265
if device_pixel_ratio is not ...:
266
params["devicePixelRatio"] = device_pixel_ratio
267
268
cmd = command_builder("browsingContext.setViewport", params)
269
result = self._conn.execute(cmd)
270
return result''',
271
],
272
# Non-CDDL download event dataclasses (Chromium-specific)
273
"extra_dataclasses": [
274
'''@dataclass
275
class DownloadWillBeginParams:
276
"""DownloadWillBeginParams."""
277
278
suggested_filename: str | None = None''',
279
'''@dataclass
280
class DownloadCanceledParams:
281
"""DownloadCanceledParams."""
282
283
status: Any | None = None''',
284
'''@dataclass
285
class DownloadParams:
286
"""DownloadParams - fields shared by all download end event variants."""
287
288
status: str | None = None
289
context: Any | None = None
290
navigation: Any | None = None
291
timestamp: Any | None = None
292
url: str | None = None
293
filepath: str | None = None''',
294
'''@dataclass
295
class DownloadEndParams:
296
"""DownloadEndParams - params for browsingContext.downloadEnd event."""
297
298
download_params: DownloadParams | None = None
299
300
@classmethod
301
def from_json(cls, params: dict) -> DownloadEndParams:
302
"""Deserialize from BiDi wire-level params dict."""
303
dp = DownloadParams(
304
status=params.get("status"),
305
context=params.get("context"),
306
navigation=params.get("navigation"),
307
timestamp=params.get("timestamp"),
308
url=params.get("url"),
309
filepath=params.get("filepath"),
310
)
311
return cls(download_params=dp)''',
312
],
313
# Download events are now in the CDDL spec, so no extra_events needed
314
},
315
"log": {
316
# Make LogLevel an alias for Level so existing code using LogLevel works
317
"aliases": {"LogLevel": "Level"},
318
# Replace the minimal CDDL-generated versions with richer ones that have from_json
319
"exclude_types": ["JavascriptLogEntry"],
320
"extra_dataclasses": [
321
'''@dataclass
322
class ConsoleLogEntry:
323
"""ConsoleLogEntry - a console log entry from the browser."""
324
325
type_: str | None = None
326
method: str | None = None
327
args: list | None = None
328
level: Any | None = None
329
text: Any | None = None
330
source: Any | None = None
331
timestamp: Any | None = None
332
stack_trace: Any | None = None
333
334
@classmethod
335
def from_json(cls, params: dict) -> ConsoleLogEntry:
336
"""Deserialize from BiDi params dict."""
337
return cls(
338
type_=params.get("type"),
339
method=params.get("method"),
340
args=params.get("args"),
341
level=params.get("level"),
342
text=params.get("text"),
343
source=params.get("source"),
344
timestamp=params.get("timestamp"),
345
stack_trace=params.get("stackTrace"),
346
)''',
347
'''@dataclass
348
class JavascriptLogEntry:
349
"""JavascriptLogEntry - a JavaScript error log entry from the browser."""
350
351
type_: str | None = None
352
level: Any | None = None
353
text: Any | None = None
354
source: Any | None = None
355
timestamp: Any | None = None
356
stacktrace: Any | None = None
357
358
@classmethod
359
def from_json(cls, params: dict) -> JavascriptLogEntry:
360
"""Deserialize from BiDi params dict."""
361
return cls(
362
type_=params.get("type"),
363
level=params.get("level"),
364
text=params.get("text"),
365
source=params.get("source"),
366
timestamp=params.get("timestamp"),
367
stacktrace=params.get("stackTrace"),
368
)''',
369
],
370
# Define Entry union type for log.entryAdded event deserialization
371
"extra_type_aliases": [
372
"Entry = GenericLogEntry | ConsoleLogEntry | JavascriptLogEntry",
373
],
374
"event_type_aliases": {
375
"entry_added": "Entry",
376
},
377
},
378
"emulation": {
379
"exclude_types": ["setNetworkConditionsParameters"],
380
"extra_dataclasses": [
381
'''@dataclass
382
class SetNetworkConditionsParameters:
383
"""SetNetworkConditionsParameters."""
384
385
network_conditions: Any | None = None
386
contexts: list[Any] = field(default_factory=list)
387
user_contexts: list[Any] = field(default_factory=list)
388
389
390
# Backward-compatible alias for existing imports
391
setNetworkConditionsParameters = SetNetworkConditionsParameters''',
392
],
393
"extra_methods": [
394
''' def set_geolocation_override(
395
self,
396
coordinates=None,
397
error=None,
398
contexts: list[Any] | None = None,
399
user_contexts: list[Any] | None = None,
400
):
401
"""Execute emulation.setGeolocationOverride.
402
403
Sets or clears the geolocation override for specified browsing or user contexts.
404
405
Args:
406
coordinates: A GeolocationCoordinates instance (or dict) to override the
407
position, or ``None`` to clear a previously-set override.
408
error: A GeolocationPositionError instance (or dict) to simulate a
409
position-unavailable error. Mutually exclusive with *coordinates*.
410
contexts: List of browsing context IDs to target.
411
user_contexts: List of user context IDs to target.
412
"""
413
params: dict[str, Any] = {}
414
if coordinates is not None:
415
if isinstance(coordinates, dict):
416
coords_dict = coordinates
417
else:
418
coords_dict = {}
419
if coordinates.latitude is not None:
420
coords_dict["latitude"] = coordinates.latitude
421
if coordinates.longitude is not None:
422
coords_dict["longitude"] = coordinates.longitude
423
if coordinates.accuracy is not None:
424
coords_dict["accuracy"] = coordinates.accuracy
425
if coordinates.altitude is not None:
426
coords_dict["altitude"] = coordinates.altitude
427
if coordinates.altitude_accuracy is not None:
428
coords_dict["altitudeAccuracy"] = coordinates.altitude_accuracy
429
if coordinates.heading is not None:
430
coords_dict["heading"] = coordinates.heading
431
if coordinates.speed is not None:
432
coords_dict["speed"] = coordinates.speed
433
params["coordinates"] = coords_dict
434
if error is not None:
435
if isinstance(error, dict):
436
params["error"] = error
437
else:
438
params["error"] = {
439
"type": error.type if error.type is not None else "positionUnavailable"
440
}
441
if contexts is not None:
442
params["contexts"] = contexts
443
if user_contexts is not None:
444
params["userContexts"] = user_contexts
445
cmd = command_builder("emulation.setGeolocationOverride", params)
446
result = self._conn.execute(cmd)
447
return result''',
448
''' def set_timezone_override(
449
self,
450
timezone=None,
451
contexts: list[Any] | None = None,
452
user_contexts: list[Any] | None = None,
453
):
454
"""Execute emulation.setTimezoneOverride.
455
456
Sets or clears the timezone override for specified browsing or user contexts.
457
Pass ``timezone=None`` (or omit it) to clear a previously-set override.
458
459
Args:
460
timezone: IANA timezone string (e.g. ``"America/New_York"``) or ``None``
461
to clear the override.
462
contexts: List of browsing context IDs to target.
463
user_contexts: List of user context IDs to target.
464
"""
465
params: dict[str, Any] = {"timezone": timezone}
466
if contexts is not None:
467
params["contexts"] = contexts
468
if user_contexts is not None:
469
params["userContexts"] = user_contexts
470
cmd = command_builder("emulation.setTimezoneOverride", params)
471
return self._conn.execute(cmd)''',
472
''' def set_scripting_enabled(
473
self,
474
enabled=None,
475
contexts: list[Any] | None = None,
476
user_contexts: list[Any] | None = None,
477
):
478
"""Execute emulation.setScriptingEnabled.
479
480
Enables or disables scripting for specified browsing or user contexts.
481
Pass ``enabled=None`` to restore the default behaviour.
482
483
Args:
484
enabled: ``True`` to enable scripting, ``False`` to disable it, or
485
``None`` to clear the override.
486
contexts: List of browsing context IDs to target.
487
user_contexts: List of user context IDs to target.
488
"""
489
params: dict[str, Any] = {"enabled": enabled}
490
if contexts is not None:
491
params["contexts"] = contexts
492
if user_contexts is not None:
493
params["userContexts"] = user_contexts
494
cmd = command_builder("emulation.setScriptingEnabled", params)
495
return self._conn.execute(cmd)''',
496
''' def set_user_agent_override(
497
self,
498
user_agent=None,
499
contexts: list[Any] | None = None,
500
user_contexts: list[Any] | None = None,
501
):
502
"""Execute emulation.setUserAgentOverride.
503
504
Overrides the User-Agent string for specified browsing or user contexts.
505
Pass ``user_agent=None`` to clear a previously-set override.
506
507
Args:
508
user_agent: Custom User-Agent string, or ``None`` to clear the override.
509
contexts: List of browsing context IDs to target.
510
user_contexts: List of user context IDs to target.
511
"""
512
params: dict[str, Any] = {"userAgent": user_agent}
513
if contexts is not None:
514
params["contexts"] = contexts
515
if user_contexts is not None:
516
params["userContexts"] = user_contexts
517
cmd = command_builder("emulation.setUserAgentOverride", params)
518
return self._conn.execute(cmd)''',
519
''' def set_screen_orientation_override(
520
self,
521
screen_orientation=None,
522
contexts: list[Any] | None = None,
523
user_contexts: list[Any] | None = None,
524
):
525
"""Execute emulation.setScreenOrientationOverride.
526
527
Sets or clears the screen orientation override for specified browsing or
528
user contexts.
529
530
Args:
531
screen_orientation: A :class:`ScreenOrientation` instance (or dict with
532
``natural`` and ``type`` keys) to lock the orientation, or ``None``
533
to clear a previously-set override.
534
contexts: List of browsing context IDs to target.
535
user_contexts: List of user context IDs to target.
536
"""
537
if screen_orientation is None:
538
so_value = None
539
elif isinstance(screen_orientation, dict):
540
so_value = screen_orientation
541
else:
542
natural = screen_orientation.natural
543
orientation_type = screen_orientation.type
544
so_value = {
545
"natural": natural.lower() if isinstance(natural, str) else natural,
546
"type": orientation_type.lower() if isinstance(orientation_type, str) else orientation_type,
547
}
548
params: dict[str, Any] = {"screenOrientation": so_value}
549
if contexts is not None:
550
params["contexts"] = contexts
551
if user_contexts is not None:
552
params["userContexts"] = user_contexts
553
cmd = command_builder("emulation.setScreenOrientationOverride", params)
554
return self._conn.execute(cmd)''',
555
''' def set_network_conditions(
556
self,
557
network_conditions=None,
558
offline: bool | None = None,
559
contexts: list[Any] | None = None,
560
user_contexts: list[Any] | None = None,
561
):
562
"""Execute emulation.setNetworkConditions.
563
564
Sets or clears network condition emulation for specified browsing or user
565
contexts.
566
567
Args:
568
network_conditions: A dict with the raw ``networkConditions`` value
569
(e.g. ``{"type": "offline"}``), or ``None`` to clear the override.
570
Mutually exclusive with *offline*.
571
offline: Convenience bool — ``True`` sets offline conditions,
572
``False`` clears them (sends ``null``). When provided, this takes
573
precedence over *network_conditions*.
574
contexts: List of browsing context IDs to target.
575
user_contexts: List of user context IDs to target.
576
"""
577
if offline is not None:
578
nc_value = {"type": "offline"} if offline else None
579
else:
580
nc_value = network_conditions
581
params: dict[str, Any] = {"networkConditions": nc_value}
582
if contexts is not None:
583
params["contexts"] = contexts
584
if user_contexts is not None:
585
params["userContexts"] = user_contexts
586
cmd = command_builder("emulation.setNetworkConditions", params)
587
return self._conn.execute(cmd)''',
588
''' def set_screen_settings_override(
589
self,
590
width: int | None = None,
591
height: int | None = None,
592
contexts: list[Any] | None = None,
593
user_contexts: list[Any] | None = None,
594
):
595
"""Execute emulation.setScreenSettingsOverride.
596
597
Sets or clears the screen settings override for specified browsing or user
598
contexts.
599
600
Args:
601
width: The screen width in pixels, or ``None`` to clear the override.
602
height: The screen height in pixels, or ``None`` to clear the override.
603
contexts: List of browsing context IDs to target.
604
user_contexts: List of user context IDs to target.
605
"""
606
screen_area = None
607
if width is not None or height is not None:
608
screen_area = {}
609
if width is not None:
610
screen_area["width"] = width
611
if height is not None:
612
screen_area["height"] = height
613
params: dict[str, Any] = {"screenArea": screen_area}
614
if contexts is not None:
615
params["contexts"] = contexts
616
if user_contexts is not None:
617
params["userContexts"] = user_contexts
618
cmd = command_builder("emulation.setScreenSettingsOverride", params)
619
return self._conn.execute(cmd)''',
620
],
621
},
622
"script": {
623
"extra_dataclasses": [
624
'''@dataclass
625
class DomMutation:
626
"""Represents a DOM attribute mutation event from add_dom_mutation_handler.
627
628
Attributes:
629
element_id: The ``data-__webdriver_id`` attribute value set on the
630
mutated element by the MutationObserver. Use this to locate the
631
element from the main thread if needed.
632
attribute_name: The name of the changed attribute.
633
current_value: The attribute value after the mutation (may be ``None``
634
if the attribute was removed).
635
old_value: The attribute value before the mutation.
636
"""
637
638
element_id: str | None = None
639
attribute_name: str | None = None
640
current_value: str | None = None
641
old_value: str | None = None
642
''',
643
],
644
"extra_methods": [
645
''' def execute(self, function_declaration: str, *args, context_id: str | None = None) -> Any:
646
"""Execute a function declaration in the browser context.
647
648
Args:
649
function_declaration: The function as a string, e.g. ``"() => document.title"``.
650
*args: Optional Python values to pass as arguments to the function.
651
Each value is serialised to a BiDi ``LocalValue`` automatically.
652
Supported types: ``None``, ``bool``, ``int``, ``float``
653
(including ``NaN`` and ``Infinity``), ``str``, ``list``,
654
``dict``, and ``datetime.datetime``.
655
context_id: The browsing context ID to run in. Defaults to the
656
driver\'s current window handle when a driver was provided.
657
658
Returns:
659
The inner RemoteValue result dict, or raises WebDriverException on exception.
660
"""
661
import math as _math
662
import datetime as _datetime
663
from selenium.common.exceptions import WebDriverException as _WebDriverException
664
665
def _serialize_arg(value):
666
"""Serialise a Python value to a BiDi LocalValue dict."""
667
if value is None:
668
return {"type": "null"}
669
if isinstance(value, bool):
670
return {"type": "boolean", "value": value}
671
if isinstance(value, _datetime.datetime):
672
return {"type": "date", "value": value.isoformat()}
673
if isinstance(value, float):
674
if _math.isnan(value):
675
return {"type": "number", "value": "NaN"}
676
if _math.isinf(value):
677
return {"type": "number", "value": "Infinity" if value > 0 else "-Infinity"}
678
return {"type": "number", "value": value}
679
if isinstance(value, int):
680
_MAX_SAFE_INT = 9007199254740991
681
if abs(value) > _MAX_SAFE_INT:
682
return {"type": "bigint", "value": str(value)}
683
return {"type": "number", "value": value}
684
if isinstance(value, str):
685
return {"type": "string", "value": value}
686
if isinstance(value, list):
687
return {"type": "array", "value": [_serialize_arg(v) for v in value]}
688
if isinstance(value, dict):
689
return {"type": "object", "value": [[str(k), _serialize_arg(v)] for k, v in value.items()]}
690
return value
691
692
if context_id is None and self._driver is not None:
693
try:
694
context_id = self._driver.current_window_handle
695
except Exception:
696
pass
697
target = {"context": context_id} if context_id else {}
698
serialized_args = [_serialize_arg(a) for a in args] if args else None
699
raw = self.call_function(
700
function_declaration=function_declaration,
701
await_promise=True,
702
target=target,
703
arguments=serialized_args,
704
)
705
if isinstance(raw, dict):
706
if raw.get("type") == "exception":
707
exc = raw.get("exceptionDetails", {})
708
msg = exc.get("text", str(exc)) if isinstance(exc, dict) else str(exc)
709
raise _WebDriverException(msg)
710
if raw.get("type") == "success":
711
return raw.get("result")
712
return raw''',
713
''' def _add_preload_script(
714
self,
715
function_declaration,
716
arguments=None,
717
contexts=None,
718
user_contexts=None,
719
sandbox=None,
720
):
721
"""Add a preload script with validation.
722
723
Args:
724
function_declaration: The JS function to run on page load.
725
arguments: Optional list of BiDi arguments.
726
contexts: Optional list of browsing context IDs.
727
user_contexts: Optional list of user context IDs.
728
sandbox: Optional sandbox name.
729
730
Returns:
731
script_id: The ID of the added preload script (str).
732
733
Raises:
734
ValueError: If both contexts and user_contexts are specified.
735
"""
736
if contexts is not None and user_contexts is not None:
737
raise ValueError("Cannot specify both contexts and user_contexts")
738
result = self.add_preload_script(
739
function_declaration=function_declaration,
740
arguments=arguments,
741
contexts=contexts,
742
user_contexts=user_contexts,
743
sandbox=sandbox,
744
)
745
if isinstance(result, dict):
746
return result.get("script")
747
return result''',
748
''' def _remove_preload_script(self, script_id):
749
"""Remove a preload script by ID.
750
751
Args:
752
script_id: The ID of the preload script to remove.
753
"""
754
return self.remove_preload_script(script=script_id)''',
755
''' def pin(self, function_declaration):
756
"""Pin (add) a preload script that runs on every page load.
757
758
Args:
759
function_declaration: The JS function to execute on page load.
760
761
Returns:
762
script_id: The ID of the pinned script (str).
763
"""
764
return self._add_preload_script(function_declaration)''',
765
''' def unpin(self, script_id):
766
"""Unpin (remove) a previously pinned preload script.
767
768
Args:
769
script_id: The ID returned by pin().
770
"""
771
return self._remove_preload_script(script_id=script_id)''',
772
''' def _evaluate(
773
self,
774
expression,
775
target,
776
await_promise,
777
result_ownership=None,
778
serialization_options=None,
779
user_activation=None,
780
):
781
"""Evaluate a script expression and return a structured result.
782
783
Args:
784
expression: The JavaScript expression to evaluate.
785
target: A dict like {"context": <id>} or {"realm": <id>}.
786
await_promise: Whether to await a returned promise.
787
result_ownership: Optional result ownership setting.
788
serialization_options: Optional serialization options dict.
789
user_activation: Optional user activation flag.
790
791
Returns:
792
An object with .realm, .result (dict or None), and .exception_details (or None).
793
"""
794
class _EvalResult:
795
def __init__(self2, realm, result, exception_details):
796
self2.realm = realm
797
self2.result = result
798
self2.exception_details = exception_details
799
800
raw = self.evaluate(
801
expression=expression,
802
target=target,
803
await_promise=await_promise,
804
result_ownership=result_ownership,
805
serialization_options=serialization_options,
806
user_activation=user_activation,
807
)
808
if isinstance(raw, dict):
809
realm = raw.get("realm")
810
if raw.get("type") == "exception":
811
exc = raw.get("exceptionDetails")
812
return _EvalResult(realm=realm, result=None, exception_details=exc)
813
return _EvalResult(realm=realm, result=raw.get("result"), exception_details=None)
814
return _EvalResult(realm=None, result=raw, exception_details=None)''',
815
''' def _call_function(
816
self,
817
function_declaration,
818
await_promise,
819
target,
820
arguments=None,
821
result_ownership=None,
822
this=None,
823
user_activation=None,
824
serialization_options=None,
825
):
826
"""Call a function and return a structured result.
827
828
Args:
829
function_declaration: The JS function string.
830
await_promise: Whether to await the return value.
831
target: A dict like {"context": <id>}.
832
arguments: Optional list of BiDi arguments.
833
result_ownership: Optional result ownership.
834
this: Optional \'this\' binding.
835
user_activation: Optional user activation flag.
836
serialization_options: Optional serialization options dict.
837
838
Returns:
839
An object with .result (dict or None) and .exception_details (or None).
840
"""
841
class _CallResult:
842
def __init__(self2, result, exception_details):
843
self2.result = result
844
self2.exception_details = exception_details
845
846
raw = self.call_function(
847
function_declaration=function_declaration,
848
await_promise=await_promise,
849
target=target,
850
arguments=arguments,
851
result_ownership=result_ownership,
852
this=this,
853
user_activation=user_activation,
854
serialization_options=serialization_options,
855
)
856
if isinstance(raw, dict):
857
if raw.get("type") == "exception":
858
exc = raw.get("exceptionDetails")
859
return _CallResult(result=None, exception_details=exc)
860
if raw.get("type") == "success":
861
return _CallResult(result=raw.get("result"), exception_details=None)
862
return _CallResult(result=raw, exception_details=None)''',
863
''' def _get_realms(self, context=None, type=None):
864
"""Get all realms, optionally filtered by context and type.
865
866
Args:
867
context: Optional browsing context ID to filter by.
868
type: Optional realm type string to filter by (e.g. RealmType.WINDOW).
869
870
Returns:
871
List of realm info objects with .realm, .origin, .type, .context attributes.
872
"""
873
class _RealmInfo:
874
def __init__(self2, realm, origin, type_, context):
875
self2.realm = realm
876
self2.origin = origin
877
self2.type = type_
878
self2.context = context
879
880
raw = self.get_realms(context=context, type=type)
881
realms_list = raw.get("realms", []) if isinstance(raw, dict) else []
882
result = []
883
for r in realms_list:
884
if isinstance(r, dict):
885
result.append(_RealmInfo(
886
realm=r.get("realm"),
887
origin=r.get("origin"),
888
type_=r.get("type"),
889
context=r.get("context"),
890
))
891
return result''',
892
''' def _disown(self, handles, target):
893
"""Disown handles in a browsing context.
894
895
Args:
896
handles: List of handle strings to disown.
897
target: A dict like {"context": <id>}.
898
"""
899
return self.disown(handles=handles, target=target)''',
900
''' def _subscribe_log_entry(self, callback, entry_type_filter=None):
901
"""Subscribe to log.entryAdded BiDi events with optional type filtering."""
902
import threading as _threading
903
from selenium.webdriver.common.bidi.session import Session as _Session
904
from selenium.webdriver.common.bidi import log as _log_mod
905
906
bidi_event = "log.entryAdded"
907
908
if not hasattr(self, "_log_subscriptions"):
909
self._log_subscriptions = {}
910
self._log_lock = _threading.Lock()
911
912
def _deserialize(params):
913
t = params.get("type") if isinstance(params, dict) else None
914
if t == "console":
915
cls = getattr(_log_mod, "ConsoleLogEntry", None)
916
if cls is not None and hasattr(cls, "from_json"):
917
try:
918
return cls.from_json(params)
919
except Exception:
920
pass
921
elif t == "javascript":
922
cls = getattr(_log_mod, "JavascriptLogEntry", None)
923
if cls is not None and hasattr(cls, "from_json"):
924
try:
925
return cls.from_json(params)
926
except Exception:
927
pass
928
return params
929
930
def _wrapped(raw):
931
entry = _deserialize(raw)
932
if entry_type_filter is None:
933
callback(entry)
934
else:
935
t = getattr(entry, "type_", None) or (
936
entry.get("type") if isinstance(entry, dict) else None
937
)
938
if t == entry_type_filter:
939
callback(entry)
940
941
class _BidiRef:
942
event_class = bidi_event
943
944
def from_json(self2, p):
945
return p
946
947
_wrapper = _BidiRef()
948
callback_id = self._conn.add_callback(_wrapper, _wrapped)
949
with self._log_lock:
950
if bidi_event not in self._log_subscriptions:
951
session = _Session(self._conn)
952
result = session.subscribe([bidi_event])
953
sub_id = (
954
result.get("subscription") if isinstance(result, dict) else None
955
)
956
self._log_subscriptions[bidi_event] = {
957
"callbacks": [],
958
"subscription_id": sub_id,
959
}
960
self._log_subscriptions[bidi_event]["callbacks"].append(callback_id)
961
return callback_id''',
962
''' def _unsubscribe_log_entry(self, callback_id):
963
"""Unsubscribe a log entry callback by ID."""
964
from selenium.webdriver.common.bidi.session import Session as _Session
965
966
bidi_event = "log.entryAdded"
967
if not hasattr(self, "_log_subscriptions"):
968
return
969
970
class _BidiRef:
971
event_class = bidi_event
972
973
def from_json(self2, p):
974
return p
975
976
_wrapper = _BidiRef()
977
self._conn.remove_callback(_wrapper, callback_id)
978
with self._log_lock:
979
entry = self._log_subscriptions.get(bidi_event)
980
if entry and callback_id in entry["callbacks"]:
981
entry["callbacks"].remove(callback_id)
982
if entry is not None and not entry["callbacks"]:
983
session = _Session(self._conn)
984
sub_id = entry.get("subscription_id")
985
if sub_id:
986
session.unsubscribe(subscriptions=[sub_id])
987
else:
988
session.unsubscribe(events=[bidi_event])
989
del self._log_subscriptions[bidi_event]''',
990
''' def add_console_message_handler(self, callback: Callable) -> int:
991
"""Add a handler for console log messages (log.entryAdded type=console).
992
993
Args:
994
callback: Function called with a ConsoleLogEntry on each console message.
995
996
Returns:
997
callback_id for use with remove_console_message_handler.
998
"""
999
return self._subscribe_log_entry(callback, entry_type_filter="console")''',
1000
''' def remove_console_message_handler(self, callback_id: int) -> None:
1001
"""Remove a console message handler by callback ID."""
1002
self._unsubscribe_log_entry(callback_id)''',
1003
''' def add_javascript_error_handler(self, callback: Callable) -> int:
1004
"""Add a handler for JavaScript error log messages (log.entryAdded type=javascript).
1005
1006
Args:
1007
callback: Function called with a JavascriptLogEntry on each JS error.
1008
1009
Returns:
1010
callback_id for use with remove_javascript_error_handler.
1011
"""
1012
return self._subscribe_log_entry(callback, entry_type_filter="javascript")''',
1013
''' def remove_javascript_error_handler(self, callback_id: int) -> None:
1014
"""Remove a JavaScript error handler by callback ID."""
1015
self._unsubscribe_log_entry(callback_id)''',
1016
''' def _subscribe_mutation_handler(self, callback):
1017
"""Subscribe to DOM mutation events using a BiDi preload script and script.message channel.
1018
1019
Loads bidi-mutation-listener.js as a preload script with a channel argument,
1020
then subscribes to script.message events from that channel to detect
1021
DOM attribute mutations.
1022
"""
1023
import json as _json
1024
import pkgutil as _pkgutil
1025
import threading as _threading
1026
from selenium.webdriver.common.bidi.session import Session as _Session
1027
1028
bidi_event = "script.message"
1029
1030
if not hasattr(self, "_mutation_subscriptions"):
1031
self._mutation_subscriptions = {}
1032
self._mutation_lock = _threading.Lock()
1033
1034
# Load bidi-mutation-listener.js only once (cache it on the instance)
1035
if not hasattr(self, "_bidi_mutation_listener_js"):
1036
_pkg = "selenium.webdriver.common"
1037
_js_bytes = _pkgutil.get_data(_pkg, "bidi-mutation-listener.js")
1038
if _js_bytes is None:
1039
raise ValueError("Failed to load bidi-mutation-listener.js")
1040
self._bidi_mutation_listener_js = _js_bytes.decode("utf8").strip()
1041
1042
# Use a stable, namespaced channel to avoid collisions with user scripts.
1043
if not hasattr(self, "_mutation_channel_name"):
1044
import uuid as _uuid
1045
self._mutation_channel_name = f"selenium.domMutation.{_uuid.uuid4().hex}"
1046
_channel_name = self._mutation_channel_name
1047
_channel_arg = {"type": "channel", "value": {"channel": _channel_name}}
1048
1049
def _on_message(message):
1050
# Filter to only our channel
1051
channel = message.get("channel") if isinstance(message, dict) else None
1052
if channel != _channel_name:
1053
return
1054
data = message.get("data", {}) if isinstance(message, dict) else {}
1055
value = data.get("value") if isinstance(data, dict) else None
1056
if value is None:
1057
return
1058
try:
1059
payload = _json.loads(value)
1060
except (ValueError, TypeError):
1061
return
1062
target_id = payload.get("target")
1063
if not target_id and target_id != 0:
1064
return
1065
from selenium.webdriver.common.bidi.script import DomMutation as _DomMutation
1066
event = _DomMutation(
1067
element_id=str(target_id),
1068
attribute_name=payload.get("name"),
1069
current_value=payload.get("value"),
1070
old_value=payload.get("oldValue"),
1071
)
1072
callback(event)
1073
1074
class _BidiRef:
1075
event_class = bidi_event
1076
1077
def from_json(self2, p):
1078
return p
1079
1080
with self._mutation_lock:
1081
# Register the preload script only once per Script instance to avoid
1082
# accumulating duplicate MutationObservers across handler registrations.
1083
if not hasattr(self, "_mutation_preload_script_id"):
1084
self._mutation_preload_script_id = self._add_preload_script(
1085
self._bidi_mutation_listener_js, arguments=[_channel_arg]
1086
)
1087
# Also invoke immediately on the current page since the preload
1088
# script only fires on future document creations.
1089
if self._driver is not None:
1090
_context = None
1091
try:
1092
_context = self._driver.current_window_handle
1093
except Exception:
1094
pass
1095
if _context is not None:
1096
self.call_function(
1097
function_declaration=self._bidi_mutation_listener_js,
1098
target={"context": _context},
1099
await_promise=False,
1100
arguments=[_channel_arg],
1101
)
1102
if bidi_event not in self._mutation_subscriptions:
1103
session = _Session(self._conn)
1104
result = session.subscribe([bidi_event])
1105
sub_id = (
1106
result.get("subscription") if isinstance(result, dict) else None
1107
)
1108
self._mutation_subscriptions[bidi_event] = {
1109
"callbacks": [],
1110
"subscription_id": sub_id,
1111
}
1112
# Register the callback AFTER setup to avoid leaking it if setup fails.
1113
_wrapper = _BidiRef()
1114
callback_id = self._conn.add_callback(_wrapper, _on_message)
1115
self._mutation_subscriptions[bidi_event]["callbacks"].append(callback_id)
1116
return callback_id''',
1117
''' def _unsubscribe_mutation_handler(self, callback_id):
1118
"""Unsubscribe a DOM mutation handler by callback ID."""
1119
from selenium.webdriver.common.bidi.session import Session as _Session
1120
1121
bidi_event = "script.message"
1122
if not hasattr(self, "_mutation_subscriptions"):
1123
return
1124
1125
class _BidiRef:
1126
event_class = bidi_event
1127
1128
def from_json(self2, p):
1129
return p
1130
1131
_wrapper = _BidiRef()
1132
self._conn.remove_callback(_wrapper, callback_id)
1133
with self._mutation_lock:
1134
entry = self._mutation_subscriptions.get(bidi_event)
1135
if entry and callback_id in entry["callbacks"]:
1136
entry["callbacks"].remove(callback_id)
1137
if entry is not None and not entry["callbacks"]:
1138
session = _Session(self._conn)
1139
sub_id = entry.get("subscription_id")
1140
if sub_id:
1141
session.unsubscribe(subscriptions=[sub_id])
1142
else:
1143
session.unsubscribe(events=[bidi_event])
1144
del self._mutation_subscriptions[bidi_event]
1145
if hasattr(self, "_mutation_preload_script_id"):
1146
preload_script_id = self._mutation_preload_script_id
1147
try:
1148
self._remove_preload_script(preload_script_id)
1149
finally:
1150
del self._mutation_preload_script_id''',
1151
''' def add_dom_mutation_handler(self, callback: Callable) -> int:
1152
"""Add a handler for DOM attribute mutation events.
1153
1154
Uses a BiDi preload script and channel to observe DOM attribute mutations
1155
on the page. When an attribute changes, the callback is invoked with a
1156
``DomMutation`` object describing the element and attribute change.
1157
1158
Args:
1159
callback: Function called with a ``DomMutation`` on each attribute mutation.
1160
1161
Returns:
1162
callback_id for use with remove_dom_mutation_handler.
1163
"""
1164
return self._subscribe_mutation_handler(callback)''',
1165
''' def remove_dom_mutation_handler(self, callback_id: int) -> None:
1166
"""Remove a DOM mutation handler by callback ID."""
1167
self._unsubscribe_mutation_handler(callback_id)''',
1168
],
1169
},
1170
"network": {
1171
"exclude_types": ["disownDataParameters"],
1172
# Initialize intercepts tracking list and per-handler intercept map
1173
"extra_init_code": [
1174
"self.intercepts: list[Any] = []",
1175
"self._handler_intercepts: dict[str, Any] = {}",
1176
],
1177
# Request class wraps a beforeRequestSent event params and provides actions
1178
"extra_dataclasses": [
1179
'''@dataclass
1180
class DisownDataParameters:
1181
"""DisownDataParameters."""
1182
1183
data_type: Any | None = None
1184
collector: Any | None = None
1185
request: Any | None = None
1186
1187
1188
# Backward-compatible alias for existing imports
1189
disownDataParameters = DisownDataParameters''',
1190
'''class BytesValue:
1191
"""A string or base64-encoded bytes value used in cookie operations.
1192
1193
This corresponds to network.BytesValue in the WebDriver BiDi specification,
1194
wrapping either a plain string or a base64-encoded binary value.
1195
"""
1196
1197
TYPE_STRING = "string"
1198
TYPE_BASE64 = "base64"
1199
1200
def __init__(self, type: Any | None, value: Any | None) -> None:
1201
self.type = type
1202
self.value = value
1203
1204
def to_bidi_dict(self) -> dict:
1205
return {"type": self.type, "value": self.value}''',
1206
'''class Request:
1207
"""Wraps a BiDi network request event params and provides request action methods."""
1208
1209
def __init__(self, conn, params):
1210
self._conn = conn
1211
self._params = params if isinstance(params, dict) else {}
1212
req = self._params.get("request", {}) or {}
1213
self.url = req.get("url", "")
1214
self._request_id = req.get("request")
1215
1216
def continue_request(self, **kwargs):
1217
"""Continue the intercepted request.
1218
1219
Data URLs (``data:``) are skipped silently because browsers do not
1220
create an interceptable request entry for them, so calling
1221
``network.continueRequest`` would raise "no such request".
1222
"""
1223
if self.url.startswith("data:"):
1224
return
1225
from selenium.webdriver.common.bidi.common import command_builder as _cb
1226
1227
params = {"request": self._request_id}
1228
params.update(kwargs)
1229
self._conn.execute(_cb("network.continueRequest", params))''',
1230
],
1231
# Override auth_required to use raw dict so _auth_callback receives all
1232
# fields (including "request") from the BiDi event params. The
1233
# generated AuthRequiredParameters dataclass only contains "response",
1234
# losing the "request" field that holds the request ID required to call
1235
# network.continueWithAuth. extra_events entries appear last in the
1236
# EVENT_CONFIGS dict literal, so this duplicate key overrides the
1237
# CDDL-generated entry.
1238
# Add before_request event (maps to network.beforeRequestSent)
1239
"extra_events": [
1240
{
1241
"event_key": "auth_required",
1242
"bidi_event": "network.authRequired",
1243
"event_class": "dict",
1244
},
1245
{
1246
"event_key": "before_request",
1247
"bidi_event": "network.beforeRequestSent",
1248
"event_class": "dict",
1249
},
1250
],
1251
"extra_methods": [
1252
''' def _add_intercept(self, phases=None, url_patterns=None):
1253
"""Add a low-level network intercept.
1254
1255
Args:
1256
phases: list of intercept phases (default: ["beforeRequestSent"])
1257
url_patterns: optional URL patterns to filter
1258
1259
Returns:
1260
dict with "intercept" key containing the intercept ID
1261
"""
1262
from selenium.webdriver.common.bidi.common import command_builder as _cb
1263
1264
if phases is None:
1265
phases = ["beforeRequestSent"]
1266
params = {"phases": phases}
1267
if url_patterns:
1268
params["urlPatterns"] = url_patterns
1269
result = self._conn.execute(_cb("network.addIntercept", params))
1270
if result:
1271
intercept_id = result.get("intercept")
1272
if intercept_id and intercept_id not in self.intercepts:
1273
self.intercepts.append(intercept_id)
1274
return result''',
1275
''' def _remove_intercept(self, intercept_id):
1276
"""Remove a low-level network intercept."""
1277
from selenium.webdriver.common.bidi.common import command_builder as _cb
1278
1279
self._conn.execute(_cb("network.removeIntercept", {"intercept": intercept_id}))
1280
if intercept_id in self.intercepts:
1281
self.intercepts.remove(intercept_id)''',
1282
''' def _canonical_request_handler_event(self, event):
1283
"""Map public request-handler aliases to supported event keys."""
1284
event_aliases = {
1285
"auth_required": "auth_required",
1286
"before_request": "before_request",
1287
"before_request_sent": "before_request",
1288
}
1289
canonical_event = event_aliases.get(event)
1290
if canonical_event is None:
1291
available_events = ", ".join(sorted(event_aliases))
1292
raise ValueError(
1293
f"Unsupported request handler event '{event}'. Available events: {available_events}"
1294
)
1295
return canonical_event''',
1296
''' def add_request_handler(self, event, callback, url_patterns=None):
1297
"""Add a handler for network requests at the specified phase.
1298
1299
Args:
1300
event: Event name, e.g. ``"before_request"`` or ``"before_request_sent"``.
1301
callback: Callable receiving a :class:`Request` instance.
1302
url_patterns: optional list of URL pattern dicts to filter.
1303
1304
Returns:
1305
callback_id int for later removal via remove_request_handler.
1306
"""
1307
canonical_event = self._canonical_request_handler_event(event)
1308
phase_map = {
1309
"before_request": "beforeRequestSent",
1310
"auth_required": "authRequired",
1311
}
1312
phase = phase_map[canonical_event]
1313
intercept_result = self._add_intercept(phases=[phase], url_patterns=url_patterns)
1314
intercept_id = intercept_result.get("intercept") if intercept_result else None
1315
1316
def _request_callback(params):
1317
raw = (
1318
params
1319
if isinstance(params, dict)
1320
else (params.__dict__ if hasattr(params, "__dict__") else {})
1321
)
1322
request = Request(self._conn, raw)
1323
callback(request)
1324
1325
callback_id = self.add_event_handler(canonical_event, _request_callback)
1326
if intercept_id:
1327
self._handler_intercepts[callback_id] = intercept_id
1328
return callback_id''',
1329
''' def remove_request_handler(self, event, callback_id):
1330
"""Remove a network request handler and its associated network intercept.
1331
1332
Args:
1333
event: The event name used when adding the handler.
1334
callback_id: The int returned by add_request_handler.
1335
"""
1336
canonical_event = self._canonical_request_handler_event(event)
1337
self.remove_event_handler(canonical_event, callback_id)
1338
intercept_id = self._handler_intercepts.pop(callback_id, None)
1339
if intercept_id:
1340
self._remove_intercept(intercept_id)''',
1341
''' def clear_request_handlers(self):
1342
"""Clear all request handlers and remove all tracked intercepts."""
1343
self.clear_event_handlers()
1344
for intercept_id in list(self.intercepts):
1345
self._remove_intercept(intercept_id)''',
1346
''' def add_auth_handler(self, username, password):
1347
"""Add an auth handler that automatically provides credentials.
1348
1349
Args:
1350
username: The username for basic authentication.
1351
password: The password for basic authentication.
1352
1353
Returns:
1354
callback_id int for later removal via remove_auth_handler.
1355
"""
1356
from selenium.webdriver.common.bidi.common import command_builder as _cb
1357
1358
# Set up network intercept for authRequired phase
1359
intercept_result = self._add_intercept(phases=["authRequired"])
1360
intercept_id = intercept_result.get("intercept") if intercept_result else None
1361
1362
def _auth_callback(params):
1363
raw = (
1364
params
1365
if isinstance(params, dict)
1366
else (params.__dict__ if hasattr(params, "__dict__") else {})
1367
)
1368
request_id = (
1369
raw.get("request", {}).get("request")
1370
if isinstance(raw, dict)
1371
else None
1372
)
1373
if request_id:
1374
self._conn.execute(
1375
_cb(
1376
"network.continueWithAuth",
1377
{
1378
"request": request_id,
1379
"action": "provideCredentials",
1380
"credentials": {
1381
"type": "password",
1382
"username": username,
1383
"password": password,
1384
},
1385
},
1386
)
1387
)
1388
1389
callback_id = self.add_event_handler("auth_required", _auth_callback)
1390
if intercept_id:
1391
self._handler_intercepts[callback_id] = intercept_id
1392
return callback_id''',
1393
''' def remove_auth_handler(self, callback_id):
1394
"""Remove an auth handler by callback ID and its associated network intercept.
1395
1396
Args:
1397
callback_id: The handler ID returned by add_auth_handler.
1398
"""
1399
self.remove_event_handler("auth_required", callback_id)
1400
intercept_id = self._handler_intercepts.pop(callback_id, None)
1401
if intercept_id:
1402
self._remove_intercept(intercept_id)''',
1403
],
1404
},
1405
"storage": {
1406
# Exclude auto-generated dataclasses that need custom to_bidi_dict()
1407
# for JSON-over-WebSocket serialization, or custom constructors.
1408
"exclude_types": [
1409
"CookieFilter",
1410
"PartialCookie",
1411
"BrowsingContextPartitionDescriptor",
1412
"StorageKeyPartitionDescriptor",
1413
],
1414
"extra_dataclasses": [
1415
# Re-export network types used in cookie operations so they can be
1416
# imported from selenium.webdriver.common.bidi.storage alongside
1417
# the storage-specific classes.
1418
'''class BytesValue:
1419
"""A string or base64-encoded bytes value used in cookie operations.
1420
1421
This corresponds to network.BytesValue in the WebDriver BiDi specification,
1422
wrapping either a plain string or a base64-encoded binary value.
1423
"""
1424
1425
TYPE_STRING = "string"
1426
TYPE_BASE64 = "base64"
1427
1428
def __init__(self, type: Any | None, value: Any | None) -> None:
1429
self.type = type
1430
self.value = value
1431
1432
def to_bidi_dict(self) -> dict:
1433
return {"type": self.type, "value": self.value}
1434
1435
def to_dict(self) -> dict:
1436
"""Backward-compatible alias for to_bidi_dict()."""
1437
return self.to_bidi_dict()''',
1438
'''class SameSite:
1439
"""SameSite cookie attribute values."""
1440
1441
STRICT = "strict"
1442
LAX = "lax"
1443
NONE = "none"
1444
DEFAULT = "default"''',
1445
# Helper: cookie object returned inside a GetCookiesResult response
1446
'''@dataclass
1447
class StorageCookie:
1448
"""A cookie object returned by storage.getCookies."""
1449
1450
name: str | None = None
1451
value: Any | None = None
1452
domain: str | None = None
1453
path: str | None = None
1454
size: Any | None = None
1455
http_only: bool | None = None
1456
secure: bool | None = None
1457
same_site: Any | None = None
1458
expiry: Any | None = None
1459
1460
@classmethod
1461
def from_bidi_dict(cls, raw: dict) -> StorageCookie:
1462
"""Deserialize a wire-level cookie dict to a StorageCookie."""
1463
value_raw = raw.get("value")
1464
if isinstance(value_raw, dict):
1465
value: Any = BytesValue(value_raw.get("type"), value_raw.get("value"))
1466
else:
1467
value = value_raw
1468
return cls(
1469
name=raw.get("name"),
1470
value=value,
1471
domain=raw.get("domain"),
1472
path=raw.get("path"),
1473
size=raw.get("size"),
1474
http_only=raw.get("httpOnly"),
1475
secure=raw.get("secure"),
1476
same_site=raw.get("sameSite"),
1477
expiry=raw.get("expiry"),
1478
)''',
1479
# Custom CookieFilter with camelCase serialization
1480
'''@dataclass
1481
class CookieFilter:
1482
"""CookieFilter."""
1483
1484
name: str | None = None
1485
value: Any | None = None
1486
domain: str | None = None
1487
path: str | None = None
1488
size: Any | None = None
1489
http_only: bool | None = None
1490
secure: bool | None = None
1491
same_site: Any | None = None
1492
expiry: Any | None = None
1493
1494
def to_bidi_dict(self) -> dict:
1495
"""Serialize to the BiDi wire-protocol dict."""
1496
result: dict = {}
1497
if self.name is not None:
1498
result["name"] = self.name
1499
if self.value is not None:
1500
result["value"] = self.value.to_bidi_dict() if hasattr(self.value, "to_bidi_dict") else self.value
1501
if self.domain is not None:
1502
result["domain"] = self.domain
1503
if self.path is not None:
1504
result["path"] = self.path
1505
if self.size is not None:
1506
result["size"] = self.size
1507
if self.http_only is not None:
1508
result["httpOnly"] = self.http_only
1509
if self.secure is not None:
1510
result["secure"] = self.secure
1511
if self.same_site is not None:
1512
result["sameSite"] = self.same_site
1513
if self.expiry is not None:
1514
result["expiry"] = self.expiry
1515
return result
1516
1517
def to_dict(self) -> dict:
1518
"""Backward-compatible alias for to_bidi_dict()."""
1519
return self.to_bidi_dict()''',
1520
# Custom PartialCookie with camelCase serialization
1521
'''@dataclass
1522
class PartialCookie:
1523
"""PartialCookie."""
1524
1525
name: str | None = None
1526
value: Any | None = None
1527
domain: str | None = None
1528
path: str | None = None
1529
http_only: bool | None = None
1530
secure: bool | None = None
1531
same_site: Any | None = None
1532
expiry: Any | None = None
1533
1534
def to_bidi_dict(self) -> dict:
1535
"""Serialize to the BiDi wire-protocol dict."""
1536
result: dict = {}
1537
if self.name is not None:
1538
result["name"] = self.name
1539
if self.value is not None:
1540
result["value"] = self.value.to_bidi_dict() if hasattr(self.value, "to_bidi_dict") else self.value
1541
if self.domain is not None:
1542
result["domain"] = self.domain
1543
if self.path is not None:
1544
result["path"] = self.path
1545
if self.http_only is not None:
1546
result["httpOnly"] = self.http_only
1547
if self.secure is not None:
1548
result["secure"] = self.secure
1549
if self.same_site is not None:
1550
result["sameSite"] = self.same_site
1551
if self.expiry is not None:
1552
result["expiry"] = self.expiry
1553
return result
1554
1555
def to_dict(self) -> dict:
1556
"""Backward-compatible alias for to_bidi_dict()."""
1557
return self.to_bidi_dict()''',
1558
# BrowsingContextPartitionDescriptor: first positional arg is *context*
1559
# (the auto-generated dataclass had `type` first, breaking positional
1560
# usage like BrowsingContextPartitionDescriptor(driver.current_window_handle))
1561
'''class BrowsingContextPartitionDescriptor:
1562
"""BrowsingContextPartitionDescriptor.
1563
1564
The first positional argument is *context* (a browsing-context ID / window
1565
handle), mirroring how the class is used throughout the test suite:
1566
``BrowsingContextPartitionDescriptor(driver.current_window_handle)``.
1567
"""
1568
1569
def __init__(self, context: Any = None, type: str = "context") -> None:
1570
self.context = context
1571
self.type = type
1572
1573
def to_bidi_dict(self) -> dict:
1574
return {"type": "context", "context": self.context}
1575
1576
def to_dict(self) -> dict:
1577
"""Backward-compatible alias for to_bidi_dict()."""
1578
return self.to_bidi_dict()''',
1579
# StorageKeyPartitionDescriptor with camelCase serialization
1580
'''@dataclass
1581
class StorageKeyPartitionDescriptor:
1582
"""StorageKeyPartitionDescriptor."""
1583
1584
type: Any | None = "storageKey"
1585
user_context: str | None = None
1586
source_origin: str | None = None
1587
1588
def to_bidi_dict(self) -> dict:
1589
"""Serialize to the BiDi wire-protocol dict."""
1590
result: dict = {"type": "storageKey"}
1591
if self.user_context is not None:
1592
result["userContext"] = self.user_context
1593
if self.source_origin is not None:
1594
result["sourceOrigin"] = self.source_origin
1595
return result
1596
1597
def to_dict(self) -> dict:
1598
"""Backward-compatible alias for to_bidi_dict()."""
1599
return self.to_bidi_dict()''',
1600
],
1601
# Override the generated Storage class methods (Python's last-definition-
1602
# wins semantics means these extra_methods shadow the generated ones).
1603
"extra_methods": [
1604
''' def get_cookies(self, filter=None, partition=None):
1605
"""Execute storage.getCookies and return a GetCookiesResult."""
1606
if filter and hasattr(filter, "to_bidi_dict"):
1607
filter = filter.to_bidi_dict()
1608
if partition and hasattr(partition, "to_bidi_dict"):
1609
partition = partition.to_bidi_dict()
1610
params = {
1611
"filter": filter,
1612
"partition": partition,
1613
}
1614
params = {k: v for k, v in params.items() if v is not None}
1615
cmd = command_builder("storage.getCookies", params)
1616
result = self._conn.execute(cmd)
1617
if result and "cookies" in result:
1618
cookies = [
1619
StorageCookie.from_bidi_dict(c)
1620
for c in result.get("cookies", [])
1621
if isinstance(c, dict)
1622
]
1623
pk_raw = result.get("partitionKey")
1624
pk = (
1625
PartitionKey(
1626
user_context=pk_raw.get("userContext"),
1627
source_origin=pk_raw.get("sourceOrigin"),
1628
)
1629
if isinstance(pk_raw, dict)
1630
else None
1631
)
1632
return GetCookiesResult(cookies=cookies, partition_key=pk)
1633
return GetCookiesResult(cookies=[], partition_key=None)''',
1634
''' def set_cookie(self, cookie=None, partition=None):
1635
"""Execute storage.setCookie."""
1636
if cookie and hasattr(cookie, "to_bidi_dict"):
1637
cookie = cookie.to_bidi_dict()
1638
if partition and hasattr(partition, "to_bidi_dict"):
1639
partition = partition.to_bidi_dict()
1640
params = {
1641
"cookie": cookie,
1642
"partition": partition,
1643
}
1644
params = {k: v for k, v in params.items() if v is not None}
1645
cmd = command_builder("storage.setCookie", params)
1646
result = self._conn.execute(cmd)
1647
if isinstance(result, dict):
1648
pk_raw = result.get("partitionKey")
1649
pk = (
1650
PartitionKey(
1651
user_context=pk_raw.get("userContext"),
1652
source_origin=pk_raw.get("sourceOrigin"),
1653
)
1654
if isinstance(pk_raw, dict)
1655
else None
1656
)
1657
return SetCookieResult(partition_key=pk)
1658
return result''',
1659
''' def delete_cookies(self, filter=None, partition=None):
1660
"""Execute storage.deleteCookies."""
1661
if filter and hasattr(filter, "to_bidi_dict"):
1662
filter = filter.to_bidi_dict()
1663
if partition and hasattr(partition, "to_bidi_dict"):
1664
partition = partition.to_bidi_dict()
1665
params = {
1666
"filter": filter,
1667
"partition": partition,
1668
}
1669
params = {k: v for k, v in params.items() if v is not None}
1670
cmd = command_builder("storage.deleteCookies", params)
1671
result = self._conn.execute(cmd)
1672
if isinstance(result, dict):
1673
pk_raw = result.get("partitionKey")
1674
pk = (
1675
PartitionKey(
1676
user_context=pk_raw.get("userContext"),
1677
source_origin=pk_raw.get("sourceOrigin"),
1678
)
1679
if isinstance(pk_raw, dict)
1680
else None
1681
)
1682
return DeleteCookiesResult(partition_key=pk)
1683
return result''',
1684
],
1685
},
1686
"session": {
1687
# Override UserPromptHandler to add to_bidi_dict() for JSON serialization
1688
"exclude_types": ["UserPromptHandler"],
1689
"extra_dataclasses": [
1690
'''@dataclass
1691
class UserPromptHandler:
1692
"""UserPromptHandler."""
1693
1694
alert: Any | None = None
1695
before_unload: Any | None = None
1696
confirm: Any | None = None
1697
default: Any | None = None
1698
file: Any | None = None
1699
prompt: Any | None = None
1700
1701
def to_bidi_dict(self) -> dict:
1702
"""Convert to BiDi protocol dict with camelCase keys."""
1703
result = {}
1704
if self.alert is not None:
1705
result["alert"] = self.alert
1706
if self.before_unload is not None:
1707
result["beforeUnload"] = self.before_unload
1708
if self.confirm is not None:
1709
result["confirm"] = self.confirm
1710
if self.default is not None:
1711
result["default"] = self.default
1712
if self.file is not None:
1713
result["file"] = self.file
1714
if self.prompt is not None:
1715
result["prompt"] = self.prompt
1716
return result
1717
1718
def to_dict(self) -> dict:
1719
"""Backward-compatible alias for to_bidi_dict()."""
1720
return self.to_bidi_dict()''',
1721
],
1722
},
1723
"webExtension": {
1724
# Suppress the raw generated stubs; hand-written versions follow below
1725
"exclude_methods": ["install", "uninstall"],
1726
"extra_methods": [
1727
''' def install(
1728
self,
1729
path: str | None = None,
1730
archive_path: str | None = None,
1731
base64_value: str | None = None,
1732
):
1733
"""Install a web extension.
1734
1735
Exactly one of the three keyword arguments must be provided.
1736
1737
Args:
1738
path: Directory path to an unpacked extension (also accepted for
1739
signed ``.xpi`` / ``.crx`` archive files on Firefox).
1740
archive_path: File-system path to a packed extension archive.
1741
base64_value: Base64-encoded extension archive string.
1742
1743
Returns:
1744
The raw result dict from the BiDi ``webExtension.install`` command
1745
(contains at least an ``"extension"`` key with the extension ID).
1746
1747
Raises:
1748
ValueError: If more than one, or none, of the arguments is provided.
1749
"""
1750
provided = [
1751
k for k, v in {
1752
"path": path, "archive_path": archive_path, "base64_value": base64_value,
1753
}.items() if v is not None
1754
]
1755
if len(provided) != 1:
1756
raise ValueError(
1757
f"Exactly one of path, archive_path, or base64_value must be provided; got: {provided}"
1758
)
1759
if path is not None:
1760
extension_data = {"type": "path", "path": path}
1761
elif archive_path is not None:
1762
extension_data = {"type": "archivePath", "path": archive_path}
1763
else:
1764
assert base64_value is not None
1765
extension_data = {"type": "base64", "value": base64_value}
1766
params = {"extensionData": extension_data}
1767
cmd = command_builder("webExtension.install", params)
1768
try:
1769
return self._conn.execute(cmd)
1770
except Exception as e:
1771
if "Method not available" in str(e):
1772
raise RuntimeError(
1773
"webExtension.install failed with 'Method not available'. "
1774
"This likely means that web extension support is disabled. "
1775
"Enable unsafe extension debugging and/or set options.enable_webextensions "
1776
"in your WebDriver configuration."
1777
) from e
1778
raise''',
1779
''' def uninstall(self, extension: str | dict):
1780
"""Uninstall a web extension.
1781
1782
Args:
1783
extension: Either the extension ID string returned by ``install``,
1784
or the full result dict returned by ``install`` (the
1785
``"extension"`` value is extracted automatically).
1786
1787
Raises:
1788
ValueError: If extension is not provided or is None.
1789
"""
1790
if isinstance(extension, dict):
1791
extension_id: Any = extension.get("extension")
1792
else:
1793
extension_id = extension
1794
1795
if extension_id is None:
1796
raise ValueError("extension parameter is required")
1797
1798
params = {"extension": extension_id}
1799
cmd = command_builder("webExtension.uninstall", params)
1800
return self._conn.execute(cmd)''',
1801
],
1802
},
1803
"input": {
1804
# FileDialogInfo needs from_json for event deserialization
1805
"exclude_types": ["FileDialogInfo", "PointerMoveAction", "PointerDownAction"],
1806
"extra_dataclasses": [
1807
'''@dataclass
1808
class FileDialogInfo:
1809
"""FileDialogInfo - parameters for the input.fileDialogOpened event."""
1810
1811
context: Any | None = None
1812
element: Any | None = None
1813
multiple: bool | None = None
1814
1815
@classmethod
1816
def from_json(cls, params: dict) -> FileDialogInfo:
1817
"""Deserialize event params into FileDialogInfo."""
1818
return cls(
1819
context=params.get("context"),
1820
element=params.get("element"),
1821
multiple=params.get("multiple"),
1822
)''',
1823
'''@dataclass
1824
class PointerMoveAction:
1825
"""PointerMoveAction."""
1826
1827
type: str = field(default="pointerMove", init=False)
1828
x: Any | None = None
1829
y: Any | None = None
1830
duration: Any | None = None
1831
origin: Any | None = None
1832
properties: Any | None = None''',
1833
'''@dataclass
1834
class PointerDownAction:
1835
"""PointerDownAction."""
1836
1837
type: str = field(default="pointerDown", init=False)
1838
button: Any | None = None
1839
properties: Any | None = None''',
1840
],
1841
"extra_methods": [
1842
''' def add_file_dialog_handler(self, callback) -> int:
1843
"""Subscribe to the input.fileDialogOpened event.
1844
1845
Args:
1846
callback: Callable invoked with a FileDialogInfo when a file dialog opens.
1847
1848
Returns:
1849
A handler ID that can be passed to remove_file_dialog_handler.
1850
"""
1851
return self._event_manager.add_event_handler("file_dialog_opened", callback)
1852
1853
def remove_file_dialog_handler(self, handler_id: int) -> None:
1854
"""Unsubscribe a previously registered file dialog event handler.
1855
1856
Args:
1857
handler_id: The handler ID returned by add_file_dialog_handler.
1858
"""
1859
return self._event_manager.remove_event_handler("file_dialog_opened", handler_id)''',
1860
],
1861
},
1862
"permissions": {
1863
"module_docstring": (
1864
"WebDriver BiDi permissions module.\n\n"
1865
"Provides control over browser permission grants during automated tests,\n"
1866
"as specified by the W3C Permissions specification.\n\n"
1867
"Typical usage::\n\n"
1868
" driver.permissions.set_permission('geolocation', 'granted', origin)\n"
1869
),
1870
"class_docstrings": {
1871
"PermissionState": (
1872
"Permission state constants.\n\n"
1873
"GRANTED: The permission is granted — the browser will not prompt the user.\n"
1874
"DENIED: The permission is denied — the browser will block the request.\n"
1875
"PROMPT: The browser will show a permission prompt (default browser behaviour)."
1876
),
1877
"Permissions": (
1878
"BiDi interface for controlling browser permissions.\n\nAccess via ``driver.permissions``."
1879
),
1880
},
1881
"extra_dataclasses": [
1882
'''class PermissionDescriptor:
1883
"""Descriptor identifying a permission by name.
1884
1885
Args:
1886
name: The permission name (e.g. 'geolocation', 'microphone', 'camera').
1887
"""
1888
1889
def __init__(self, name: str) -> None:
1890
self.name = name
1891
1892
def __repr__(self) -> str:
1893
return f"PermissionDescriptor(name={self.name!r})"''',
1894
],
1895
"extra_methods": [
1896
''' def set_permission(
1897
self,
1898
descriptor: "PermissionDescriptor | str",
1899
state: "PermissionState | str",
1900
origin: str | None = None,
1901
user_context: str | None = None,
1902
*,
1903
embedded_origin: str | None = None,
1904
) -> None:
1905
"""Set a browser permission.
1906
1907
Args:
1908
descriptor: The permission descriptor or permission name as a string.
1909
state: The desired permission state (granted, denied, or prompt).
1910
origin: The origin to scope the permission to.
1911
user_context: Optional user context ID to scope the permission.
1912
embedded_origin: Keyword-only. Embedded origin for cross-origin
1913
iframes; scopes the permission to that iframe's origin.
1914
1915
Raises:
1916
ValueError: If *state* is not a valid permission state.
1917
"""
1918
state_value = state.value if isinstance(state, PermissionState) else state
1919
valid_states = {"granted", "denied", "prompt"}
1920
if state_value not in valid_states:
1921
raise ValueError(
1922
f"Invalid permission state: {state_value!r}. "
1923
f"Must be one of {sorted(valid_states)}"
1924
)
1925
1926
descriptor_dict = {"name": descriptor} if isinstance(descriptor, str) else {"name": descriptor.name}
1927
1928
params: dict = {
1929
"descriptor": descriptor_dict,
1930
"state": state_value,
1931
}
1932
if origin is not None:
1933
params["origin"] = origin
1934
if embedded_origin is not None:
1935
params["embeddedOrigin"] = embedded_origin
1936
if user_context is not None:
1937
params["userContext"] = user_context
1938
1939
cmd = command_builder("permissions.setPermission", params)
1940
self._conn.execute(cmd)''',
1941
],
1942
},
1943
"bluetooth": {
1944
"module_docstring": (
1945
"WebDriver BiDi bluetooth module.\n\n"
1946
"Provides a simulation API for Web Bluetooth, allowing tests to fake\n"
1947
"Bluetooth adapters, nearby peripherals, GATT services, characteristics,\n"
1948
"and descriptors without physical hardware.\n"
1949
),
1950
"class_docstrings": {
1951
"Bluetooth": (
1952
"BiDi interface for simulating Web Bluetooth hardware.\n\n"
1953
"Simulate adapters, peripherals, GATT services, characteristics,\n"
1954
"and descriptors without physical hardware."
1955
),
1956
"RequestDeviceInfo": (
1957
"Identifies a simulated Bluetooth device returned in a device-request prompt.\n\n"
1958
"Attributes:\n"
1959
" id: The internal device identifier.\n"
1960
" name: The human-readable device name shown in the prompt."
1961
),
1962
"SimulateAdapterParameters": (
1963
"Parameters for simulating a Bluetooth adapter state.\n\n"
1964
"Attributes:\n"
1965
" context: The browsing context ID to target.\n"
1966
" le_supported: Whether the adapter supports Bluetooth Low Energy.\n"
1967
" state: Adapter power state (e.g. 'powered-on', 'powered-off', 'absent')."
1968
),
1969
"SimulatePreconnectedPeripheralParameters": (
1970
"Parameters for adding a pre-connected simulated peripheral.\n\n"
1971
"Attributes:\n"
1972
" context: The browsing context ID to target.\n"
1973
" address: The Bluetooth device address (e.g. '09:09:09:09:09:09').\n"
1974
" name: The device name advertised to the page.\n"
1975
" manufacturer_data: List of manufacturer-specific data records.\n"
1976
" known_service_uuids: UUIDs of GATT services the device exposes."
1977
),
1978
"SimulateAdvertisementParameters": (
1979
"Parameters for injecting a simulated advertisement packet.\n\n"
1980
"Attributes:\n"
1981
" context: The browsing context ID to target.\n"
1982
" scan_entry: The advertisement scan record to inject."
1983
),
1984
"SimulateGattConnectionResponseParameters": (
1985
"Parameters for simulating a GATT connection response.\n\n"
1986
"Attributes:\n"
1987
" context: The browsing context ID to target.\n"
1988
" address: The address of the peripheral.\n"
1989
" code: The ATT error code (0 = success)."
1990
),
1991
"SimulateCharacteristicParameters": (
1992
"Parameters for adding a simulated GATT characteristic to a service.\n\n"
1993
"Attributes:\n"
1994
" context: The browsing context ID to target.\n"
1995
" address: The peripheral address.\n"
1996
" service: The service UUID the characteristic belongs to.\n"
1997
" characteristic: UUID of the characteristic.\n"
1998
" properties: Supported operations (read, write, notify, etc.)."
1999
),
2000
},
2001
"command_docstrings": {
2002
"handle_request_device_prompt": (
2003
"Dismiss or accept a Bluetooth device-chooser prompt.\n\n"
2004
"Args:\n"
2005
" context: The browsing context containing the prompt.\n"
2006
" prompt: The prompt ID returned in the prompt-opened event."
2007
),
2008
"simulate_adapter": (
2009
"Simulate a Bluetooth adapter in the given browsing context.\n\n"
2010
"Args:\n"
2011
" context: The browsing context ID to target.\n"
2012
" le_supported: Whether Low Energy is supported.\n"
2013
" state: Adapter state ('powered-on', 'powered-off', 'absent')."
2014
),
2015
"disable_simulation": (
2016
"Disable all Bluetooth simulation in the given context, restoring real behaviour.\n\n"
2017
"Args:\n"
2018
" context: The browsing context ID to stop simulating."
2019
),
2020
"simulate_preconnected_peripheral": (
2021
"Register a simulated peripheral as already connected to the adapter.\n\n"
2022
"Args:\n"
2023
" context: The browsing context ID to target.\n"
2024
" address: The Bluetooth device address.\n"
2025
" name: The device name.\n"
2026
" manufacturer_data: Manufacturer-specific advertisement data.\n"
2027
" known_service_uuids: List of GATT service UUIDs the device exposes."
2028
),
2029
"simulate_advertisement": (
2030
"Inject a simulated Bluetooth advertisement packet.\n\n"
2031
"Args:\n"
2032
" context: The browsing context ID to target.\n"
2033
" scan_entry: The advertisement scan record to inject."
2034
),
2035
"simulate_gatt_connection_response": (
2036
"Respond to a pending GATT connection attempt from the page.\n\n"
2037
"Args:\n"
2038
" context: The browsing context ID.\n"
2039
" address: The peripheral address.\n"
2040
" code: ATT error code (0 = success; non-zero signals failure)."
2041
),
2042
"simulate_gatt_disconnection": (
2043
"Simulate a GATT disconnection for the given peripheral.\n\n"
2044
"Args:\n"
2045
" context: The browsing context ID.\n"
2046
" address: The address of the peripheral to disconnect."
2047
),
2048
"simulate_service": (
2049
"Add a simulated GATT service to a peripheral.\n\n"
2050
"Args:\n"
2051
" context: The browsing context ID.\n"
2052
" address: The peripheral address.\n"
2053
" uuid: The service UUID."
2054
),
2055
"simulate_characteristic": (
2056
"Add a simulated GATT characteristic to a service.\n\n"
2057
"Args:\n"
2058
" context: The browsing context ID.\n"
2059
" address: The peripheral address.\n"
2060
" service: The service UUID.\n"
2061
" characteristic: The characteristic UUID.\n"
2062
" properties: Supported operations bitmap."
2063
),
2064
"simulate_characteristic_response": (
2065
"Respond to a pending read or write on a simulated characteristic.\n\n"
2066
"Args:\n"
2067
" context: The browsing context ID.\n"
2068
" address: The peripheral address.\n"
2069
" service: The service UUID.\n"
2070
" characteristic: The characteristic UUID.\n"
2071
" code: ATT error code (0 = success).\n"
2072
" body: The characteristic value bytes (for reads)."
2073
),
2074
"simulate_descriptor": (
2075
"Add a simulated GATT descriptor to a characteristic.\n\n"
2076
"Args:\n"
2077
" context: The browsing context ID.\n"
2078
" address: The peripheral address.\n"
2079
" service: The service UUID.\n"
2080
" characteristic: The characteristic UUID.\n"
2081
" descriptor: The descriptor UUID."
2082
),
2083
"simulate_descriptor_response": (
2084
"Respond to a pending read or write on a simulated descriptor.\n\n"
2085
"Args:\n"
2086
" context: The browsing context ID.\n"
2087
" address: The peripheral address.\n"
2088
" service: The service UUID.\n"
2089
" characteristic: The characteristic UUID.\n"
2090
" descriptor: The descriptor UUID.\n"
2091
" code: ATT error code (0 = success).\n"
2092
" body: The descriptor value bytes (for reads)."
2093
),
2094
},
2095
},
2096
"speculation": {
2097
"module_docstring": (
2098
"WebDriver BiDi speculation module.\n\n"
2099
"Provides events for observing the status of Speculation Rules prefetch\n"
2100
"requests initiated by the browser (e.g. via <script type='speculationrules'>).\n"
2101
),
2102
"class_docstrings": {
2103
"Speculation": ("BiDi interface for observing Speculation Rules prefetch activity."),
2104
"PreloadingStatus": (
2105
"Status values for a speculation-rules prefetch operation.\n\n"
2106
"PENDING: The prefetch has been queued but not yet attempted.\n"
2107
"READY: The prefetch succeeded and the resource is cached.\n"
2108
"SUCCESS: The prefetched navigation was used successfully.\n"
2109
"FAILURE: The prefetch failed or was cancelled."
2110
),
2111
"PrefetchStatusUpdatedParameters": (
2112
"Event payload emitted when a prefetch status changes.\n\n"
2113
"Attributes:\n"
2114
" context: The browsing context ID that owns the speculation rule.\n"
2115
" url: The URL being prefetched.\n"
2116
" status: The new prefetch status (see PreloadingStatus)."
2117
),
2118
},
2119
},
2120
"userAgentClientHints": {
2121
"module_docstring": (
2122
"WebDriver BiDi userAgentClientHints module.\n\n"
2123
"Provides an API for overriding the User-Agent Client Hints reported\n"
2124
"by the browser, enabling tests to simulate different devices, platforms,\n"
2125
"and browser brands without changing the actual browser binary.\n"
2126
),
2127
"class_docstrings": {
2128
"UserAgentClientHints": ("BiDi interface for overriding User-Agent Client Hints."),
2129
"ClientHintsMetadata": (
2130
"Full set of User-Agent Client Hint values to override.\n\n"
2131
"Attributes:\n"
2132
" brands: List of browser brand/version pairs (e.g. [BrandVersion('Chrome', '120')]).\n"
2133
" full_version_list: Brands with full version strings.\n"
2134
" platform: Operating system name (e.g. 'Windows', 'macOS').\n"
2135
" platform_version: OS version string.\n"
2136
" architecture: CPU architecture (e.g. 'x86', 'arm').\n"
2137
" model: Device model (primarily for mobile).\n"
2138
" mobile: True if the UA should appear to be a mobile device.\n"
2139
" bitness: Pointer-size bitness string ('32' or '64').\n"
2140
" wow64: True if running a 32-bit process on 64-bit Windows.\n"
2141
" form_factors: Device form factors (e.g. 'Desktop', 'Phone')."
2142
),
2143
"BrandVersion": (
2144
"A single browser brand entry used in Client Hints brand lists.\n\n"
2145
"Attributes:\n"
2146
" brand: The browser/engine brand name (e.g. 'Google Chrome').\n"
2147
" version: The major or full version string (e.g. '120')."
2148
),
2149
},
2150
},
2151
}
2152
2153
2154
# ============================================================================
2155
# Pre-processing Functions
2156
# ============================================================================
2157
2158
2159
def check_serialize_method(obj: Any) -> Any:
2160
"""Check if object has to_bidi_dict() method and use it for serialization."""
2161
if obj and hasattr(obj, "to_bidi_dict"):
2162
return obj.to_bidi_dict()
2163
return obj
2164
2165
2166
# ============================================================================
2167
# Validation Functions
2168
# ============================================================================
2169
2170
2171
def validate_download_behavior(
2172
allowed: bool | None,
2173
destination_folder: str | None,
2174
user_contexts: Any | None = None,
2175
) -> None:
2176
"""Validate download behavior parameters.
2177
2178
Args:
2179
allowed: Whether downloads are allowed
2180
destination_folder: Destination folder for downloads
2181
user_contexts: Optional list of user contexts (ignored for validation)
2182
2183
Raises:
2184
ValueError: If parameters are invalid
2185
"""
2186
if allowed is True and not destination_folder:
2187
raise ValueError("destination_folder is required when allowed=True")
2188
if allowed is False and destination_folder:
2189
raise ValueError("destination_folder should not be provided when allowed=False")
2190
2191
2192
# ============================================================================
2193
# Transformation Functions
2194
# ============================================================================
2195
2196
2197
def transform_download_params(
2198
allowed: bool | None,
2199
destination_folder: str | None,
2200
) -> dict[str, Any]:
2201
"""Transform download parameters into download_behavior object.
2202
2203
Args:
2204
allowed: Whether downloads are allowed
2205
destination_folder: Destination folder for downloads
2206
2207
Returns:
2208
Dictionary representing the download_behavior object, or None if allowed is None
2209
"""
2210
if allowed is True:
2211
return {
2212
"type": "allowed",
2213
# Convert pathlib.Path (or any path-like) to str so the BiDi
2214
# protocol always receives a plain JSON string.
2215
"destinationFolder": (str(destination_folder) if destination_folder is not None else None),
2216
}
2217
elif allowed is False:
2218
return {"type": "denied"}
2219
else: # None — reset to browser default (sent as JSON null)
2220
return None
2221
2222
2223
# ============================================================================
2224
# Dataclass Method Templates
2225
# ============================================================================
2226
2227
DATACLASS_METHOD_TEMPLATES: dict[str, dict[str, str]] = {
2228
"ClientWindowInfo": {
2229
"get_client_window": "return self.client_window",
2230
"get_state": "return self.state",
2231
"get_width": "return self.width",
2232
"get_height": "return self.height",
2233
"is_active": "return self.active",
2234
"get_x": "return self.x",
2235
"get_y": "return self.y",
2236
},
2237
"BrowsingContext": {
2238
"add_event_handler": "_add_event_handler_impl",
2239
"remove_event_handler": "_remove_event_handler_impl",
2240
},
2241
}
2242
2243
DATACLASS_METHOD_DOCSTRINGS: dict[str, dict[str, str]] = {
2244
"ClientWindowInfo": {
2245
"get_client_window": "Get the client window ID.",
2246
"get_state": "Get the client window state.",
2247
"get_width": "Get the client window width.",
2248
"get_height": "Get the client window height.",
2249
"is_active": "Check if the client window is active.",
2250
"get_x": "Get the client window X position.",
2251
"get_y": "Get the client window Y position.",
2252
},
2253
"BrowsingContext": {
2254
"add_event_handler": "Add an event handler for browsing context events.",
2255
"remove_event_handler": "Remove an event handler by callback ID.",
2256
},
2257
}
2258
2259
# ============================================================================
2260
# Event Handler Support for BrowsingContext
2261
# ============================================================================
2262
2263
2264
def _add_event_handler(
2265
self,
2266
event_name: str,
2267
callback: callable,
2268
contexts: list[str] | None = None,
2269
) -> str:
2270
"""Add an event handler for a browsing context event.
2271
2272
Supported events:
2273
- 'context_created'
2274
- 'context_destroyed'
2275
- 'navigation_started'
2276
- 'navigation_committed'
2277
- 'navigation_failed'
2278
- 'dom_content_loaded'
2279
- 'load'
2280
- 'fragment_navigated'
2281
- 'user_prompt_opened'
2282
- 'user_prompt_closed'
2283
- 'download_will_begin'
2284
- 'download_end'
2285
- 'history_updated'
2286
2287
Args:
2288
self: The module instance this handler is bound to.
2289
event_name: The name of the event to subscribe to
2290
callback: Callback function to invoke when event occurs
2291
contexts: Optional list of context IDs to limit event subscription
2292
2293
Returns:
2294
A callback ID that can be used to unsubscribe the handler
2295
"""
2296
if not hasattr(self, "_event_handlers"):
2297
self._event_handlers = {}
2298
self._event_callback_id_counter = 0
2299
2300
# Generate unique callback ID
2301
self._event_callback_id_counter += 1
2302
callback_id = f"callback_{self._event_callback_id_counter}"
2303
2304
# Store the handler
2305
self._event_handlers[callback_id] = {
2306
"event": event_name,
2307
"callback": callback,
2308
"contexts": contexts,
2309
}
2310
2311
# Subscribe via the driver's event listening mechanism
2312
if hasattr(self._driver, "_subscribe_event"):
2313
self._driver._subscribe_event(event_name, callback, contexts)
2314
2315
return callback_id
2316
2317
2318
def _remove_event_handler(
2319
self,
2320
callback_id: str,
2321
) -> None:
2322
"""Remove an event handler by its callback ID.
2323
2324
Args:
2325
self: The module instance this handler is bound to.
2326
callback_id: The callback ID returned from add_event_handler
2327
"""
2328
if not hasattr(self, "_event_handlers"):
2329
return
2330
2331
if callback_id in self._event_handlers:
2332
handler_info = self._event_handlers[callback_id]
2333
2334
# Unsubscribe from the driver
2335
if hasattr(self._driver, "_unsubscribe_event"):
2336
self._driver._unsubscribe_event(
2337
handler_info["event"],
2338
handler_info["callback"],
2339
handler_info["contexts"],
2340
)
2341
2342
del self._event_handlers[callback_id]
2343
2344