Skip to content

Commit e7881b4

Browse files
committed
feat(tab): add TabProtocol; converge execute_script to Python values
Add TabProtocol (portable tab contract) + a mypy conformance harness (browser/_conformance.py) asserting both Tab (CDP) and BiDiTab (BiDi) satisfy it. BREAKING (CDP): Tab.execute_script now returns the script's deserialized Python value (was the raw CDP response) and takes (script, *args); the CDP power kwargs move to the escape hatch (execute_protocol_command(RuntimeCommands.evaluate(...))). Both protocols now behave identically: same call -> same Python value -> same ScriptExecutionError on throw. BiDiTab.execute_script gains *args support. Also: Tab.get_cookies/set_cookies use the generic Cookie/CookieParam types; BiDiTab.take_screenshot/print_to_pdf gain quality/beyond_viewport and display_header_footer/print_background for CDP parity; new ScriptExecutionError raised by both backends. Remove the now-dead _get_evaluate_command and _validate_argument_error.
1 parent 10ca705 commit e7881b4

5 files changed

Lines changed: 255 additions & 254 deletions

File tree

pydoll/browser/_conformance.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Static conformance checks (mypy-only): concrete classes satisfy the portable protocols.
2+
3+
This module has no runtime effect. The assignments exist purely so mypy verifies that the
4+
concrete CDP and BiDi tab classes implement the portable protocol surface, keeping the two
5+
backends from drifting apart. Check with::
6+
7+
mypy --follow-imports=silent pydoll/browser/_conformance.py
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from typing import TYPE_CHECKING
13+
14+
if TYPE_CHECKING:
15+
from pydoll.browser.chromium.tab import Tab
16+
from pydoll.browser.firefox.tab import BiDiTab
17+
from pydoll.browser.protocols import TabProtocol
18+
19+
def _cdp_tab_conforms(tab: Tab) -> TabProtocol:
20+
return tab
21+
22+
def _bidi_tab_conforms(tab: BiDiTab) -> TabProtocol:
23+
return tab

pydoll/browser/chromium/tab.py

Lines changed: 43 additions & 235 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import base64 as _b64
55
import contextlib
66
import io
7+
import json
78
import logging
89
import shutil
910
import warnings
@@ -49,14 +50,14 @@
4950
IFrameNotFound,
5051
InvalidFileExtension,
5152
InvalidIFrame,
52-
InvalidScriptWithElement,
5353
InvalidTabInitialization,
5454
MissingScreenshotPath,
5555
NavigationError,
5656
NetworkEventsNotEnabled,
5757
NoDialogPresent,
5858
NotAnIFrame,
5959
PageLoadTimeout,
60+
ScriptExecutionError,
6061
TopLevelTargetRequired,
6162
WaitElementTimeout,
6263
WebSocketConnectionClosed,
@@ -70,11 +71,8 @@
7071
from pydoll.protocol.cdp.page.events import PageEvent
7172
from pydoll.protocol.cdp.page.types import FrameResourceTree, ScreenshotFormat
7273
from pydoll.protocol.cdp.runtime.methods import (
73-
CallFunctionOnResponse,
7474
EvaluateResponse,
75-
SerializationOptions,
7675
)
77-
from pydoll.protocol.cdp.runtime.types import CallArgument
7876
from pydoll.protocol.cdp.target.types import TargetInfo
7977
from pydoll.protocol.events import CDP_DOMAIN_MAP, CDP_EVENT_MAP, Event
8078
from pydoll.utils import (
@@ -113,8 +111,6 @@
113111
from pydoll.protocol.cdp.network.methods import GetCookiesResponse as NetworkGetCookiesResponse
114112
from pydoll.protocol.cdp.network.methods import GetResponseBodyResponse
115113
from pydoll.protocol.cdp.network.types import (
116-
Cookie,
117-
CookieParam,
118114
ErrorReason,
119115
RequestMethod,
120116
)
@@ -126,9 +122,10 @@
126122
NavigateResponse,
127123
PrintToPDFResponse,
128124
)
129-
from pydoll.protocol.cdp.runtime.methods import CallFunctionOnResponse, EvaluateResponse
125+
from pydoll.protocol.cdp.runtime.methods import EvaluateResponse
130126
from pydoll.protocol.cdp.storage.methods import GetCookiesResponse as StorageGetCookiesResponse
131127
from pydoll.protocol.cdp.target.methods import AttachToTargetResponse, GetTargetsResponse
128+
from pydoll.protocol.types import Cookie, CookieParam
132129

133130
logger = logging.getLogger(__name__)
134131

@@ -1176,8 +1173,8 @@ async def _fetch_document_html(self, frame_tree: FrameResourceTree) -> str:
11761173
return html
11771174
except Exception:
11781175
logger.debug('getResourceContent failed for document, falling back to JS')
1179-
response = await self.execute_script('return document.documentElement.outerHTML')
1180-
return cast(str, response['result']['result']['value'])
1176+
html = await self.execute_script('return document.documentElement.outerHTML')
1177+
return cast(str, html)
11811178

11821179
async def _fetch_bundle_assets(
11831180
self,
@@ -1258,175 +1255,53 @@ async def handle_dialog(self, accept: bool, prompt_text: Optional[str] = None):
12581255
PageCommands.handle_javascript_dialog(accept=accept, prompt_text=prompt_text)
12591256
)
12601257

1261-
@overload
1262-
async def execute_script(
1263-
self,
1264-
script: str,
1265-
*,
1266-
object_group: Optional[str] = None,
1267-
include_command_line_api: Optional[bool] = None,
1268-
silent: Optional[bool] = None,
1269-
context_id: Optional[int] = None,
1270-
return_by_value: Optional[bool] = None,
1271-
generate_preview: Optional[bool] = None,
1272-
user_gesture: Optional[bool] = None,
1273-
await_promise: Optional[bool] = None,
1274-
throw_on_side_effect: Optional[bool] = None,
1275-
timeout: Optional[float] = None,
1276-
disable_breaks: Optional[bool] = None,
1277-
repl_mode: Optional[bool] = None,
1278-
allow_unsafe_eval_blocked_by_csp: Optional[bool] = None,
1279-
unique_context_id: Optional[str] = None,
1280-
serialization_options: Optional[SerializationOptions] = None,
1281-
) -> EvaluateResponse: ...
1282-
1283-
@overload
1284-
async def execute_script(
1285-
self,
1286-
script: str,
1287-
element: WebElement,
1288-
*,
1289-
arguments: Optional[list[CallArgument]] = None,
1290-
silent: Optional[bool] = None,
1291-
return_by_value: Optional[bool] = None,
1292-
generate_preview: Optional[bool] = None,
1293-
user_gesture: Optional[bool] = None,
1294-
await_promise: Optional[bool] = None,
1295-
execution_context_id: Optional[int] = None,
1296-
object_group: Optional[str] = None,
1297-
throw_on_side_effect: Optional[bool] = None,
1298-
unique_context_id: Optional[str] = None,
1299-
serialization_options: Optional[SerializationOptions] = None,
1300-
) -> CallFunctionOnResponse: ...
1301-
1302-
async def execute_script(
1303-
self,
1304-
script: str,
1305-
element: Optional[WebElement] = None,
1306-
*,
1307-
arguments: Optional[list[CallArgument]] = None,
1308-
object_group: Optional[str] = None,
1309-
include_command_line_api: Optional[bool] = None,
1310-
silent: Optional[bool] = None,
1311-
context_id: Optional[int] = None,
1312-
return_by_value: Optional[bool] = None,
1313-
generate_preview: Optional[bool] = None,
1314-
user_gesture: Optional[bool] = None,
1315-
await_promise: Optional[bool] = None,
1316-
execution_context_id: Optional[int] = None,
1317-
throw_on_side_effect: Optional[bool] = None,
1318-
timeout: Optional[float] = None,
1319-
disable_breaks: Optional[bool] = None,
1320-
repl_mode: Optional[bool] = None,
1321-
allow_unsafe_eval_blocked_by_csp: Optional[bool] = None,
1322-
unique_context_id: Optional[str] = None,
1323-
serialization_options: Optional[SerializationOptions] = None,
1324-
) -> Union[EvaluateResponse, CallFunctionOnResponse]:
1325-
"""
1326-
Execute JavaScript in page context.
1258+
async def execute_script(self, script: str, *args: object) -> object:
1259+
"""Execute JavaScript in the page and return its deserialized result.
13271260
13281261
Args:
1329-
script (str): JavaScript code to execute.
1330-
element (Optional[WebElement]): Optional WebElement to execute script on.
1331-
arguments (Optional[list[CallArgument]]): Arguments to pass to the function.
1332-
object_group (Optional[str]): Symbolic group name for the result (Runtime.evaluate).
1333-
include_command_line_api (Optional[bool]): Whether to include command line API
1334-
(Runtime.evaluate).
1335-
silent (Optional[bool]): Whether to silence exceptions (Runtime.evaluate).
1336-
context_id (Optional[int]): ID of the execution context to evaluate in
1337-
(Runtime.evaluate).
1338-
return_by_value (Optional[bool]): Whether to return the result by value instead of
1339-
reference (Runtime.evaluate).
1340-
generate_preview (Optional[bool]): Whether to generate a preview for the result
1341-
(Runtime.evaluate).
1342-
user_gesture (Optional[bool]): Whether to treat evaluation as initiated by user
1343-
gesture (Runtime.evaluate).
1344-
await_promise (Optional[bool]): Whether to await promise result (Runtime.evaluate).
1345-
execution_context_id (Optional[int]): ID of the execution context to call the
1346-
function in.
1347-
throw_on_side_effect (Optional[bool]): Whether to throw if side effect cannot be
1348-
ruled out (Runtime.evaluate).
1349-
timeout (Optional[float]): Timeout in milliseconds (Runtime.evaluate).
1350-
disable_breaks (Optional[bool]): Whether to disable breakpoints during evaluation
1351-
(Runtime.evaluate).
1352-
repl_mode (Optional[bool]): Whether to execute in REPL mode (Runtime.evaluate).
1353-
allow_unsafe_eval_blocked_by_csp (Optional[bool]): Allow unsafe evaluation
1354-
(Runtime.evaluate).
1355-
unique_context_id (Optional[str]): Unique context ID for evaluation
1356-
(Runtime.evaluate).
1357-
serialization_options (Optional[SerializationOptions]): Serialization options for
1358-
the result (Runtime.evaluate).
1262+
script: JavaScript to run. With no args, a bare ``return`` statement is
1263+
wrapped automatically. With args, pass a function expression
1264+
(e.g. ``(a, b) => a + b``) — the args are forwarded to it.
1265+
*args: JSON-serializable values forwarded to the script function.
13591266
13601267
Returns:
1361-
Union[EvaluateResponse, CallFunctionOnResponse]: The result of the script execution.
1268+
The script's return value as a Python object (None when it returns nothing).
13621269
13631270
Raises:
1364-
InvalidScriptWithElement: If script uses 'argument' keyword but no element is provided.
1271+
ScriptExecutionError: If the script throws.
13651272
1366-
Examples:
1367-
# Execute a simple script to log a message
1368-
await page.execute_script('console.log("Hello World")')
1369-
1370-
# Execute a script that returns the page title
1371-
await page.execute_script('return document.title')
1372-
1373-
# Execute a script on an element to click it
1374-
await page.execute_script('argument.click()', element)
1375-
1376-
# Execute a script on an element to set its value
1377-
await page.execute_script('argument.value = "Hello"', element)
1378-
"""
1379-
logger.debug(f'Executing script: with_element={bool(element)}, length={len(script)}')
1380-
if element is not None:
1381-
warnings.warn(
1382-
'Passing a WebElement to Tab.execute_script() is deprecated. '
1383-
'Use WebElement.execute_script() instead.',
1384-
DeprecationWarning,
1385-
stacklevel=2,
1386-
)
1273+
Note:
1274+
For fine-grained CDP control (silent, contextId, raw RemoteObject, etc.)
1275+
use ``execute_protocol_command(RuntimeCommands.evaluate(...))``.
13871276
1388-
return await element.execute_script(
1389-
script,
1390-
arguments=arguments,
1391-
silent=silent,
1392-
return_by_value=return_by_value,
1393-
generate_preview=generate_preview,
1394-
user_gesture=user_gesture,
1395-
await_promise=await_promise,
1396-
execution_context_id=execution_context_id,
1397-
object_group=object_group,
1398-
throw_on_side_effect=throw_on_side_effect,
1399-
unique_context_id=unique_context_id,
1400-
serialization_options=serialization_options,
1401-
)
1277+
Examples:
1278+
await tab.execute_script('return document.title')
1279+
await tab.execute_script('(a, b) => a + b', 2, 3)
1280+
"""
1281+
if args:
1282+
serialized = ', '.join(json.dumps(arg) for arg in args)
1283+
expression = f'({script})({serialized})'
1284+
elif has_return_outside_function(script):
1285+
expression = f'(function(){{ {script} }})()'
1286+
else:
1287+
expression = script
14021288

1403-
if has_return_outside_function(script):
1404-
script = f'(function(){{ {script} }})()'
1405-
1406-
command = self._get_evaluate_command(
1407-
script,
1408-
object_group=object_group,
1409-
include_command_line_api=include_command_line_api,
1410-
silent=silent,
1411-
context_id=context_id,
1412-
return_by_value=return_by_value,
1413-
generate_preview=generate_preview,
1414-
user_gesture=user_gesture,
1415-
await_promise=await_promise,
1416-
throw_on_side_effect=throw_on_side_effect,
1417-
timeout=timeout,
1418-
disable_breaks=disable_breaks,
1419-
repl_mode=repl_mode,
1420-
allow_unsafe_eval_blocked_by_csp=allow_unsafe_eval_blocked_by_csp,
1421-
unique_context_id=unique_context_id,
1422-
serialization_options=serialization_options,
1423-
)
1424-
logger.debug(f'Executing script without element: length={len(script)}')
1425-
result: Union[EvaluateResponse, CallFunctionOnResponse] = await self._execute_command(
1426-
command
1289+
logger.debug(f'Executing script: length={len(script)}, args={len(args)}')
1290+
response: EvaluateResponse = await self._execute_command(
1291+
RuntimeCommands.evaluate(expression, return_by_value=True, await_promise=True)
14271292
)
1428-
self._validate_argument_error(result)
1429-
return result
1293+
return self._extract_script_value(response)
1294+
1295+
@staticmethod
1296+
def _extract_script_value(response: EvaluateResponse) -> object:
1297+
"""Extract the Python value from a Runtime.evaluate response, raising on JS errors."""
1298+
evaluate_result = response.get('result', {})
1299+
exception_details = evaluate_result.get('exceptionDetails')
1300+
if exception_details:
1301+
exception = exception_details.get('exception', {})
1302+
message = exception.get('description') or exception_details.get('text', 'Script error')
1303+
raise ScriptExecutionError(message)
1304+
return evaluate_result.get('result', {}).get('value')
14301305

14311306
# TODO: think about how to remove these duplications with the base class
14321307
async def continue_request(
@@ -1849,73 +1724,6 @@ def _get_connection_handler(self) -> ConnectionHandler:
18491724
)
18501725
return ConnectionHandler(self._connection_port, self._target_id)
18511726

1852-
@staticmethod
1853-
def _get_evaluate_command(
1854-
script: str,
1855-
*,
1856-
object_group: Optional[str] = None,
1857-
include_command_line_api: Optional[bool] = None,
1858-
silent: Optional[bool] = None,
1859-
context_id: Optional[int] = None,
1860-
return_by_value: Optional[bool] = None,
1861-
generate_preview: Optional[bool] = None,
1862-
user_gesture: Optional[bool] = None,
1863-
await_promise: Optional[bool] = None,
1864-
throw_on_side_effect: Optional[bool] = None,
1865-
timeout: Optional[float] = None,
1866-
disable_breaks: Optional[bool] = None,
1867-
repl_mode: Optional[bool] = None,
1868-
allow_unsafe_eval_blocked_by_csp: Optional[bool] = None,
1869-
unique_context_id: Optional[str] = None,
1870-
serialization_options: Optional[SerializationOptions] = None,
1871-
):
1872-
"""Create an evaluate command with the given parameters."""
1873-
return RuntimeCommands.evaluate(
1874-
expression=script,
1875-
object_group=object_group,
1876-
include_command_line_api=include_command_line_api,
1877-
silent=silent,
1878-
context_id=context_id,
1879-
return_by_value=return_by_value,
1880-
generate_preview=generate_preview,
1881-
user_gesture=user_gesture,
1882-
await_promise=await_promise,
1883-
throw_on_side_effect=throw_on_side_effect,
1884-
timeout=timeout,
1885-
disable_breaks=disable_breaks,
1886-
repl_mode=repl_mode,
1887-
allow_unsafe_eval_blocked_by_csp=allow_unsafe_eval_blocked_by_csp,
1888-
unique_context_id=unique_context_id,
1889-
serialization_options=serialization_options,
1890-
)
1891-
1892-
@staticmethod
1893-
def _validate_argument_error(response: EvaluateResponse) -> None:
1894-
"""
1895-
Validate that script didn't fail with ReferenceError about 'argument' being undefined.
1896-
1897-
Raises:
1898-
InvalidScriptWithElement: If script uses 'argument' keyword but no element was provided.
1899-
"""
1900-
evaluate_result = response.get('result')
1901-
if not isinstance(evaluate_result, dict):
1902-
return
1903-
1904-
remote_object = evaluate_result.get('result')
1905-
if not isinstance(remote_object, dict):
1906-
return
1907-
1908-
if not (
1909-
remote_object.get('type') == 'object'
1910-
and remote_object.get('subtype') == 'error'
1911-
and remote_object.get('className') == 'ReferenceError'
1912-
):
1913-
return
1914-
1915-
description = remote_object.get('description', '')
1916-
if 'argument is not defined' in description:
1917-
raise InvalidScriptWithElement('Script contains "argument" but no element was provided')
1918-
19191727
_PAGE_LOAD_EVENT_MAP = {
19201728
PageLoadState.INTERACTIVE: PageEvent.DOM_CONTENT_EVENT_FIRED,
19211729
PageLoadState.COMPLETE: PageEvent.LOAD_EVENT_FIRED,

0 commit comments

Comments
 (0)