Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion cdp_use/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
from cdp_use.client import CDPClient
from cdp_use.helpers import (
CDPError,
CDPEvaluationError,
CDPResponseError,
extract_value,
extract_object_id,
extract_box_model,
extract_navigation_history,
evaluate_expression,
evaluate_with_object,
get_element_box,
cdp_session,
)

__all__ = ["CDPClient"]
__all__ = [
"CDPClient",
# Exceptions
"CDPError",
"CDPEvaluationError",
"CDPResponseError",
# Extractors
"extract_value",
"extract_object_id",
"extract_box_model",
"extract_navigation_history",
# Helpers
"evaluate_expression",
"evaluate_with_object",
"get_element_box",
"cdp_session",
]
217 changes: 217 additions & 0 deletions cdp_use/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
"""
Helper utilities for common CDP operations.

This module provides convenience functions to reduce boilerplate when working
with Chrome DevTools Protocol responses.
"""

from typing import Any, TypeVar, cast
from cdp_use.cdp.runtime.commands import EvaluateReturns
from cdp_use.cdp.runtime.types import ExceptionDetails
from cdp_use.cdp.dom.commands import GetBoxModelReturns, ResolveNodeReturns
from cdp_use.cdp.page.commands import GetNavigationHistoryReturns


T = TypeVar('T')


class CDPError(Exception):
"""Base exception for CDP-related errors."""
pass


class CDPEvaluationError(CDPError):
"""Raised when CDP evaluation fails."""
def __init__(self, exception_details: ExceptionDetails):
self.exception_details = exception_details
super().__init__(f"CDP evaluation failed: {exception_details}")


class CDPResponseError(CDPError):
"""Raised when CDP response is missing expected data."""
pass


# Runtime helpers

def extract_value(evaluate_result: EvaluateReturns, default: T | None = None) -> T | None:
"""
Extract value from Runtime.evaluate result with proper type handling.

Args:
evaluate_result: The result from Runtime.evaluate
default: Default value to return if no value is found

Returns:
The extracted value or default

Raises:
CDPEvaluationError: If the evaluation resulted in an exception
"""
if 'exceptionDetails' in evaluate_result:
raise CDPEvaluationError(evaluate_result['exceptionDetails'])

if 'result' in evaluate_result:
remote_obj = evaluate_result['result']
if 'value' in remote_obj:
return cast(T, remote_obj['value'])

return default


def extract_object_id(evaluate_result: EvaluateReturns) -> str | None:
"""
Extract objectId from Runtime.evaluate result.

Args:
evaluate_result: The result from Runtime.evaluate

Returns:
The objectId if present, None otherwise

Raises:
CDPEvaluationError: If the evaluation resulted in an exception
"""
if 'exceptionDetails' in evaluate_result:
raise CDPEvaluationError(evaluate_result['exceptionDetails'])

if 'result' in evaluate_result and 'objectId' in evaluate_result['result']:
return evaluate_result['result']['objectId']
return None


async def evaluate_expression(cdp_client, expression: str, session_id: str | None = None) -> Any:
"""
Evaluate JavaScript expression and return value directly.

Args:
cdp_client: The CDP client instance
expression: JavaScript expression to evaluate
session_id: Optional session ID for the evaluation context

Returns:
The evaluated value

Raises:
CDPEvaluationError: If the evaluation fails
"""
result: EvaluateReturns = await cdp_client.send.Runtime.evaluate(
params={'expression': expression, 'returnByValue': True},
session_id=session_id
)
return extract_value(result)


async def evaluate_with_object(cdp_client, expression: str, session_id: str | None = None) -> str:
"""
Evaluate JavaScript expression and return objectId.

Args:
cdp_client: The CDP client instance
expression: JavaScript expression to evaluate
session_id: Optional session ID for the evaluation context

Returns:
The objectId of the evaluated expression

Raises:
CDPEvaluationError: If the evaluation fails
CDPResponseError: If no objectId is returned
"""
result: EvaluateReturns = await cdp_client.send.Runtime.evaluate(
params={'expression': expression},
session_id=session_id
)
object_id = extract_object_id(result)
if not object_id:
raise CDPResponseError(f"No objectId returned for expression: {expression}")
return object_id


# DOM helpers

def extract_box_model(box_model_result: GetBoxModelReturns) -> dict[str, Any]:
"""
Extract box model from DOM.getBoxModel result.

Args:
box_model_result: The result from DOM.getBoxModel

Returns:
The box model dictionary

Raises:
CDPResponseError: If no box model is present
"""
if 'model' not in box_model_result:
raise CDPResponseError("No box model returned")
return box_model_result['model']


async def get_element_box(cdp_client, backend_node_id: int, session_id: str | None = None) -> dict[str, Any]:
"""
Get element box model with proper error handling.

Args:
cdp_client: The CDP client instance
backend_node_id: The backend node ID
session_id: Optional session ID

Returns:
The box model dictionary

Raises:
CDPResponseError: If no box model is returned
"""
result: GetBoxModelReturns = await cdp_client.send.DOM.getBoxModel(
params={'backendNodeId': backend_node_id},
session_id=session_id
)
return extract_box_model(result)


# Page helpers

def extract_navigation_history(history_result: GetNavigationHistoryReturns) -> tuple[int, list[dict[str, Any]]]:
"""
Extract current index and entries from navigation history.

Args:
history_result: The result from Page.getNavigationHistory

Returns:
Tuple of (currentIndex, entries)
"""
current_index = history_result.get('currentIndex', 0)
entries = history_result.get('entries', [])
return current_index, entries


# Context managers

from contextlib import asynccontextmanager

@asynccontextmanager
async def cdp_session(cdp_client, target_id: str):
"""
Context manager for CDP sessions.

Usage:
async with cdp_session(cdp_client, target_id) as session_id:
# Use session_id in CDP commands
await cdp_client.send.Runtime.evaluate(..., session_id=session_id)
"""
result = await cdp_client.send.Target.attachToTarget(
params={'targetId': target_id, 'flatten': True}
)
session_id = result['sessionId']
try:
yield session_id
finally:
try:
await cdp_client.send.Target.detachFromTarget(
params={'sessionId': session_id}
)
except Exception:
# Session might already be detached
pass
50 changes: 49 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "cdp-use"
version = "1.3.1"
version = "1.3.3"
description = "Type safe generator/client library for CDP"
readme = "README.md"
requires-python = ">=3.11"
Expand All @@ -17,12 +17,60 @@ build-backend = "hatchling.build"
dev = [
"build>=1.2.2.post1",
"ruff>=0.12.2",
"pyright>=1.1.403",
"pytest>=8.3.4",
]

[tool.pyright]
typeCheckingMode = "standard"
exclude = ["tests/old/", ".venv/", ".git/", "__pycache__/", "./test_*.py", "./debug_*.py", "private_example/"]
venvPath = "."
venv = ".venv"

[tool.pytest.ini_options]
timeout = 300
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
testpaths = [
"tests"
]
python_files = ["test_*.py", "*_test.py"]
addopts = "-svx --strict-markers --tb=short --dist=loadscope"
log_cli = true
log_cli_format = "%(levelname)-8s [%(name)s] %(message)s"
filterwarnings = [
"ignore::pytest.PytestDeprecationWarning",
"ignore::DeprecationWarning",
]
log_level = "DEBUG"

[tool.hatch.build]
include = [
"cdp_use/**/*.py"
]
exclude = [
"cdp_use/generator/*.py"
]


[tool.codespell]
ignore-words-list = "bu,wit,dont,cant,wont,re-use,re-used,re-using,re-usable,thats,doesnt,doubleclick"
skip = "*.json"

[tool.ruff]
line-length = 140
fix = true

[tool.ruff.lint]
select = ["ASYNC", "E", "F", "FAST", "I", "PLE"]
ignore = ["ASYNC109", "E101", "E402", "E501", "F841", "E731", "W291"] # TODO: determine if adding timeouts to all the unbounded async functions is needed / worth-it so we can un-ignore ASYNC109
unfixable = ["E101", "E402", "E501", "F841", "E731"]

[tool.ruff.format]
quote-style = "single"
indent-style = "space"
line-ending = "lf"
docstring-code-format = true
docstring-code-line-length = 140
skip-magic-trailing-comma = false
Loading