diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 578f2c05..6a78668c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,25 @@ jobs: - name: Run tests run: uv run --python ${{ matrix.python-version }} --extra orjson pytest tests -v + + api-and-integration: + name: generated API, typing and headless Chrome + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + - run: uv python install 3.14 + - run: uv sync --python 3.14 --dev --extra orjson --locked + - name: Check public API types and library sources + run: uv run --python 3.14 --extra orjson pyright + - name: Check generated code against bundled specs + run: uv run --python 3.14 --extra orjson python scripts/check_generated.py + - name: Benchmark smoke run + run: uv run --python 3.14 --extra orjson python benchmarks/runtime.py --iterations 1000 --samples 3 + - name: Set up Chrome + id: chrome + uses: browser-actions/setup-chrome@v2 + - name: Test against headless Chrome + env: + CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} + run: uv run --python 3.14 --extra orjson pytest -m integration -v diff --git a/CHANGELOG.md b/CHANGELOG.md index b8c1d339..fedbc326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,63 @@ Breaking changes are listed first in each release. ## [Unreleased] +### Breaking changes + +- Optional `any` fields now default to `UNSET`, distinguishing omission from + JSON `null`. For example, `CallArgument()` omits `value`, while + `CallArgument(value=None)` sends `{"value": null}`. Ordinary optional scalar + fields still use `None` to mean omitted. `UNSET` and `UnsetType` are public. +- `decode_cdp` now raises `CDPDecodeError` for missing required fields, including + nested models. Unknown fields and future enum values remain supported. Use + `decode_cdp(Model, payload, strict=False)` for the previous tolerant behavior + that logs a warning and fills missing required fields with `None`. +- Command timeouts now cover sending and receiving. `CdpClient.default_timeout` + is enforced for every transport and inherited by session views. Omit the raw + `execute(timeout=...)` argument to inherit the default; pass `None` to disable + it. Set `default_timeout=None` to disable the client default entirely. +- `WebSocketTransport` no longer accepts `default_timeout`; configure it on + `CdpClient` instead. Standalone transport calls have no deadline unless + `execute(timeout=seconds)` is specified. Generated methods retain only CDP's + own parameters; use `asyncio.timeout(seconds)` for an individual caller budget. +- WebSocket compression is disabled by default for lower local latency. Pass + `compression="deflate"` to `CdpClient` or `WebSocketTransport` to opt into + compression when bandwidth matters more. +- Bounded event buffers now default to `overflow="error"`: overflow terminates + only the affected stream and raises `CDPEventOverflowError` on its next read. + Pass `overflow="drop_oldest"` on the client, transport or subscription to keep + the old lossy behavior. The default queue size remains unlimited (`0`). + +### Added + +- Generated method docstrings include parameter descriptions, units and defaults + from the spec. Model fields retain their attribute documentation. Experimental + and deprecated markers are preserved on types, events and fields as well as + commands. +- `listen_raw(method=None)` on clients and sessions, and `listen_all_raw()` on + clients, return `TransportEvent` values with wire names, parameter dictionaries + and session IDs. None matches every method in the selected scope. +- Per-subscription overflow overrides and structured `CDPEventOverflowError` + details (`max_queue_size`, `dropped_events`). Overflow never blocks responses + or other subscriptions; failed listeners unregister immediately. +- `compression`, `open_timeout` and `close_timeout` options for URL-based clients + and the standalone WebSocket transport. Connection timeouts are in seconds. +- Reproducible codec, dispatch and optional Chrome round-trip benchmarks in + `benchmarks/runtime.py`; offline regeneration and generated-code drift checks + in `scripts/check_generated.py`. +- CI checks the library and public API with Pyright and runs isolated headless + Chrome tests. Run those locally with `pytest -m integration` and optionally + `CHROME_PATH`; they are excluded from the default unit-test run. + +### Performance + +- Generated command methods build wire dictionaries directly, avoiding an + intermediate Params dataclass and reflective field traversal. Public Params + models and keyword-only command signatures are retained. Nested models still + serialize through the shared codec. +- The orjson transport sends UTF-8 bytes directly as text frames and receives + bytes without first decoding to a Python string. This removes redundant + UTF-8 conversions while preserving the CDP wire format. + ## [0.4.0] - 2026-10-25 ### Breaking changes diff --git a/README.md b/README.md index 51bae23a..9a48075f 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ pip install "cdpify[websocket,orjson]" The transport automatically uses `orjson` when available and otherwise uses Python's standard `json` module. Outgoing WebSocket messages remain text frames. +Generated methods expose only CDP parameters. Parameter docstrings preserve +the spec's units, defaults and experimental/deprecated markers. + Install the generator dependencies only when regenerating protocol modules: ```bash @@ -231,11 +234,15 @@ subscription has read or discarded the incoming event. ### Limiting buffered events Set `max_queue_size` on `CdpClient` to limit each listener's buffer. The default, -`0`, is unlimited. A full buffer discards its oldest event so slow consumers do -not block command responses. Override the limit for an individual subscription: +`0`, is unlimited. A bounded buffer defaults to `overflow="error"`: if it fills, +that subscription ends and its next read raises `CDPEventOverflowError`. Other +subscriptions and command responses continue normally. This makes event loss +explicit for protocols such as `Fetch.requestPaused` that require a response. + +For a stream where loss is acceptable, select `overflow="drop_oldest"`: ```python -client = CdpClient(ws_url, max_queue_size=256) +client = CdpClient(ws_url, max_queue_size=256, overflow="drop_oldest") async with client: async with client.listen(RequestWillBeSentEvent, max_queue_size=32) as events: @@ -243,10 +250,26 @@ async with client: print(events.dropped_events) ``` -The same options apply to `listen_all()` and `CdpSession.listen()`. Passing `0` -to a listener disables its limit. `events.dropped_events` counts events evicted -by that listener's buffer. Disconnecting drains the remaining buffered events; -`aclose()` releases them immediately. +The same options apply to `listen_all()`, `CdpSession.listen()` and raw streams. +Each listener can override both the limit and overflow policy. Passing `0` +disables its limit. `events.dropped_events` counts discarded events. Disconnect +drains healthy buffers; explicit closure and overflow errors release them. + +### Raw events + +Use raw subscriptions for events outside the generated spec, or to inspect wire +parameter dictionaries without constructing typed models: + +```python +async with tab.listen_raw("Future.event") as events: + event = await events.next(timeout=5) + print(event.method, event.session_id, event.params) +``` + +`client.listen_raw()` covers the root, `tab.listen_raw()` one session, and +`client.listen_all_raw()` every scope. Omitting the method matches every event +in that scope. Subscriptions register immediately and share the same lifetime, +buffering and read-only data rules as typed streams. ## Working with target sessions @@ -305,9 +328,43 @@ client = CdpClient( max_frame_size=100 * 1024 * 1024, default_timeout=30.0, max_queue_size=256, + overflow="error", + compression=None, + open_timeout=10.0, + close_timeout=10.0, ) ``` +WebSocket compression is off by default to reduce local CPU work and latency. +Set `compression="deflate"` when saving network bandwidth is preferable. +`additional_headers`, `max_frame_size`, `compression`, `open_timeout` and +`close_timeout` configure the URL-based WebSocket transport. Supplied custom +transports own their connection settings. + +### Timeouts + +`default_timeout` is a total command budget in seconds, including sending and +waiting for the response. It applies to root commands and session commands, +including custom transports. `None` disables the default. + +For one call, or a group of calls, use Python's normal timeout context: + +```python +async with asyncio.timeout(2): + result = await client.runtime.evaluate(expression="1 + 1") +``` + +A client deadline raises `CDPTimeoutError`; a shorter outer `asyncio.timeout()` +raises the built-in `TimeoutError`. Both release the pending local request. +Cancelling the wait does not automatically cancel work in Chrome. + +CDP parameters retain the spec's units. For example, `Runtime.evaluate(timeout=...)` +limits execution inside Chrome in **milliseconds**; it is not the client budget. +The raw `execute()` escape hatch also accepts `timeout=seconds`: omit it to +inherit the client default or pass `None` to disable the deadline for that call. + +### Custom transports + For another transport protocol, implement the exported `Transport` protocol and inject it directly: @@ -323,8 +380,11 @@ WebSocket transport uses this path to avoid an intermediate event queue. Transports that implement only `Transport.events()` continue to work. When using `WebSocketTransport.events()` directly, its `max_queue_size` option -sets the same drop-oldest limit on its transport buffer; `dropped_events` counts -those evictions. A client-supplied custom transport manages its own buffer limits. +sets the limit on its transport buffer with the same overflow policies. A +client-supplied custom transport manages its own transport buffer limits. +`WebSocketTransport` has no default command timeout; standalone calls can pass +`execute(timeout=seconds)`. In the `Transport` contract, `timeout=None` means no +deadline and cancellation releases resources associated with that request. For methods not covered by the generated API, use the low-level escape hatch: @@ -351,10 +411,29 @@ than `FrameId("F1")`, and `isinstance(frame_id, str)` for a runtime check. ### When Chrome deviates from the spec -Chrome does not always send every field the protocol marks as required. -Instead of failing, cdpify logs a warning on the `cdpify.codec` logger and -sets the missing field to `None`, even if its type says otherwise. Unknown -fields from newer Chrome versions are ignored. +Missing required fields raise `CDPDecodeError` with `model_type`, +`missing_fields` and the enclosing wire-field `path`. Unknown fields are ignored +and future enum values remain supported. Decoding checks required field +presence; it does not perform full schema validation of primitive values. + +For a deliberately tolerant decode, use `decode_cdp(Model, payload, strict=False)`. +This fills missing required fields with `None` and logs a warning, including +nested models. When necessary, fetch the raw result with `execute()` and decode +it explicitly with this policy. + +### Omitted values and JSON null + +Optional scalar parameters still use `None` to mean omitted. Optional `Any` +fields use `UNSET` because JSON null is itself a valid value: + +```python +from cdpify import UNSET, encode_cdp +from cdpify.domains.runtime import CallArgument + +assert encode_cdp(CallArgument()) == {} +assert encode_cdp(CallArgument(value=None)) == {"value": None} +assert CallArgument().value is UNSET +``` Deprecated CDP commands still work but emit a `DeprecationWarning`. Use Python's warning filters to silence them or turn them into errors: @@ -376,12 +455,37 @@ Install the project and its development dependencies with uv sync --dev uv run pytest uv run ruff check . --exclude cdpify/domains +uv run pyright +uv run python scripts/check_generated.py ``` Use `uv sync --dev --extra orjson` and `uv run --extra orjson pytest` to include the optional backend. CI installs this extra and tests both JSON backends on Ubuntu, Windows, and macOS with Python 3.12, 3.13, and 3.14. +Run real Chrome coverage with `uv run --extra orjson pytest -m integration`. +These tests launch an isolated headless browser. Set `CHROME_PATH` to select its +executable. Integration tests are excluded from the default unit-test run. + +CI also checks the complete library and a public API sample with Pyright, +verifies generated-code drift, and runs the integration suite against Chrome. +Regenerate from the bundled specs with `uv run python scripts/check_generated.py --write`. + +Run reproducible microbenchmarks with: + +```bash +uv run python benchmarks/runtime.py +uv run --extra orjson python benchmarks/runtime.py --page-url ws://localhost:9222/devtools/page/... +``` + +Output is JSON with environment metadata and timings for encoding, decoding, +typed calls and dispatch to several listener counts. An optional page endpoint +adds real small-message and 1-MiB round trips, including p50 and p95 latency. +The large case sends and receives a seeded Base64 payload. Add +`--compression deflate` to compare compression on the same machine; installing +or omitting the `orjson` extra selects the JSON backend. Timings are diagnostic +and are not used as flaky CI pass/fail thresholds. + To download the latest protocol definitions and regenerate all domain clients: ```bash diff --git a/benchmarks/runtime.py b/benchmarks/runtime.py new file mode 100644 index 00000000..ee5cfa91 --- /dev/null +++ b/benchmarks/runtime.py @@ -0,0 +1,124 @@ +"""Reproducible codec/dispatch benchmarks, with optional real CDP round trips.""" + +import argparse +import asyncio +import base64 +import json +import platform +import random +import statistics +import time +import timeit + +from cdpify import CdpClient, decode_cdp, encode_cdp +from cdpify.domains.runtime import EvaluateParams, EvaluateResult +from cdpify.domains.runtime.domain import Runtime +from cdpify.events import EventRouter +from cdpify.executor import BoundCommandExecutor +from cdpify.transport import TransportEvent + +RESULT = {"result": {"type": "number", "value": 2}} + + +class Executor: + async def execute(self, **kwargs): + return RESULT + + +def sync_benchmark(fn, iterations, samples): + fn() # Compile the model plan outside the steady-state measurement. + values = timeit.repeat(fn, number=iterations, repeat=samples) + return {"median_us": statistics.median(values) * 1e6 / iterations} + + +async def async_benchmark(fn, iterations, samples): + await fn() + values = [] + for _ in range(samples): + start = time.perf_counter_ns() + for _ in range(iterations): + await fn() + values.append((time.perf_counter_ns() - start) / iterations / 1000) + return {"median_us": statistics.median(values)} + + +async def main(args): + output = { + "metadata": { + "python": platform.python_version(), + "platform": platform.platform(), + "iterations": args.iterations, + "samples": args.samples, + }, + "measurements": {}, + } + measurements = output["measurements"] + params = EvaluateParams(expression="1 + 1", return_by_value=True) + measurements["encode_evaluate_params"] = sync_benchmark( + lambda: encode_cdp(params), args.iterations, args.samples + ) + measurements["decode_evaluate_result"] = sync_benchmark( + lambda: decode_cdp(EvaluateResult, RESULT), args.iterations, args.samples + ) + runtime = Runtime(Executor()) + measurements["typed_evaluate_mock"] = await async_benchmark( + lambda: runtime.evaluate(expression="1 + 1", return_by_value=True), + args.iterations, + args.samples, + ) + client_runtime = Runtime(BoundCommandExecutor(Executor())) + measurements["typed_evaluate_with_client_deadline"] = await async_benchmark( + lambda: client_runtime.evaluate(expression="1 + 1", return_by_value=True), + args.iterations, + args.samples, + ) + for count in (0, 1, 10, 100): + router = EventRouter(max_queue_size=1, overflow="drop_oldest") + subscriptions = [router.listen_raw(session_id=None) for _ in range(count)] + event = TransportEvent("Benchmark.event", {"value": 1}) + measurements[f"dispatch_{count}_listeners"] = await async_benchmark( + lambda: router.dispatch(event), args.iterations, args.samples + ) + for subscription in subscriptions: + await subscription.aclose() + if args.page_url: + async with CdpClient(args.page_url, compression=args.compression) as client: + for _ in range(20): + await client.runtime.evaluate(expression="1 + 1", return_by_value=True) + measurements["chrome_small_rtt"] = await round_trips(client, "1 + 1", 200) + # A seeded Base64 payload approximates already-compressed CDP data + # better than a repeated character. This measures 1 MiB both ways. + payload = base64.b64encode(random.Random(42).randbytes(786432)).decode( + "ascii" + ) + measurements["chrome_1mib_rtt"] = await round_trips( + client, json.dumps(payload), 30 + ) + output["metadata"]["browser"] = (await client.browser.get_version()).product + output["metadata"]["compression"] = args.compression + print(json.dumps(output, indent=2)) + + +async def round_trips(client, expression, count): + values = [] + for _ in range(count): + start = time.perf_counter_ns() + await client.runtime.evaluate(expression=expression, return_by_value=True) + values.append((time.perf_counter_ns() - start) / 1000) + return { + "p50_us": statistics.median(values), + "p95_us": sorted(values)[int(0.95 * count)], + "count": count, + } + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--iterations", type=int, default=10000) + parser.add_argument("--samples", type=int, default=7) + parser.add_argument("--page-url", help="Optional page webSocketDebuggerUrl") + parser.add_argument("--compression", choices=["deflate"], default=None) + options = parser.parse_args() + if options.iterations <= 0 or options.samples <= 0: + parser.error("iterations and samples must be positive") + asyncio.run(main(options)) diff --git a/cdpify/__init__.py b/cdpify/__init__.py index 3edbfb8e..f972a02e 100644 --- a/cdpify/__init__.py +++ b/cdpify/__init__.py @@ -1,10 +1,13 @@ from .client import CdpClient from .codec import decode_cdp, encode_cdp +from .event_queue import OverflowPolicy from .events import EventSubscription, ReceivedEvent from .exceptions import ( CDPCommandError, CDPConnectionError, + CDPDecodeError, CDPError, + CDPEventOverflowError, CDPStreamClosedError, CDPTimeoutError, deprecated_exception, @@ -12,6 +15,7 @@ from .executor import CommandExecutor from .session import CdpSession from .transport import Transport, TransportEvent +from .unset import UNSET, UnsetType __all__ = [ "CdpClient", @@ -28,6 +32,11 @@ "CDPCommandError", "CDPTimeoutError", "CDPStreamClosedError", + "CDPDecodeError", + "CDPEventOverflowError", + "OverflowPolicy", + "UNSET", + "UnsetType", ] diff --git a/cdpify/client.py b/cdpify/client.py index 9099fa41..488f429f 100644 --- a/cdpify/client.py +++ b/cdpify/client.py @@ -1,14 +1,16 @@ import asyncio import logging -from typing import Any, Self +from typing import Any, Literal, Self from cdpify.domains import DomainAccessors +from cdpify.event_queue import OverflowPolicy from cdpify.events import EventRouter, EventSubscription, ReceivedEvent from cdpify.events.source import EventSource from cdpify.exceptions import CDPConnectionError from cdpify.executor import BoundCommandExecutor from cdpify.session import CdpSession from cdpify.transport import CallbackTransport, Transport, TransportEvent +from cdpify.unset import UNSET, UnsetType logger = logging.getLogger(__name__) @@ -21,8 +23,12 @@ def __init__( transport: Transport | None = None, additional_headers: dict[str, str] | None = None, max_frame_size: int = 100 * 1024 * 1024, - default_timeout: float = 30.0, + default_timeout: float | None = 30.0, max_queue_size: int = 0, + overflow: OverflowPolicy = "error", + compression: Literal["deflate"] | None = None, + open_timeout: float | None = 10.0, + close_timeout: float | None = 10.0, ) -> None: if transport is not None and url is not None: raise TypeError("Pass either url or transport, not both") @@ -33,13 +39,21 @@ def __init__( url, additional_headers=additional_headers, max_frame_size=max_frame_size, - default_timeout=default_timeout, max_queue_size=max_queue_size, + overflow=overflow, + compression=compression, + open_timeout=open_timeout, + close_timeout=close_timeout, ) self._transport = transport - self._executor = BoundCommandExecutor(transport) - EventSource.__init__(self, EventRouter(max_queue_size=max_queue_size), None) + self._default_timeout = default_timeout + self._executor: BoundCommandExecutor = BoundCommandExecutor( + transport, default_timeout=default_timeout + ) + EventSource.__init__( + self, EventRouter(max_queue_size=max_queue_size, overflow=overflow), None + ) self._event_loop_task: asyncio.Task[None] | None = None self._disconnect_task: asyncio.Task[None] | None = None @@ -97,20 +111,31 @@ async def _disconnect(self) -> None: def session(self, session_id: str) -> CdpSession: """Create an immutable domain view bound to ``session_id``.""" - return CdpSession(self._transport, self._event_router, session_id) + return CdpSession( + self._transport, + self._event_router, + session_id, + default_timeout=self._default_timeout, + ) async def execute( self, method: str, params: dict[str, Any] | None = None, session_id: str | None = None, - timeout: float | None = None, + timeout: float | None | UnsetType = UNSET, ) -> dict[str, Any]: """Execute a low-level command, optionally routed to a target session.""" - return await self._transport.execute( + executor = ( + self._executor + if session_id is None + else BoundCommandExecutor( + self._transport, session_id, default_timeout=self._default_timeout + ) + ) + return await executor.execute( method=method, params=params, - session_id=session_id, timeout=timeout, ) @@ -121,6 +146,7 @@ def listen_all[T]( timeout: float | None = None, event_name: str | None = None, max_queue_size: int | None = None, + overflow: OverflowPolicy | None = None, ) -> EventSubscription[ReceivedEvent[T]]: """Subscribe to matching events from the root and every target session. @@ -131,6 +157,25 @@ def listen_all[T]( timeout=timeout, event_name=event_name, max_queue_size=max_queue_size, + overflow=overflow, + ) + + def listen_all_raw( + self, + method: str | None = None, + *, + timeout: float | None = None, + max_queue_size: int | None = None, + overflow: OverflowPolicy | None = None, + ) -> EventSubscription[TransportEvent]: + """Listen to wire dictionaries from the root and every target session.""" + return self._event_router.listen_raw( + method, + session_id=None, + all_sessions=True, + timeout=timeout, + max_queue_size=max_queue_size, + overflow=overflow, ) async def _run_event_loop(self) -> None: @@ -169,8 +214,11 @@ def _create_websocket_transport( *, additional_headers: dict[str, str] | None, max_frame_size: int, - default_timeout: float, max_queue_size: int, + overflow: OverflowPolicy, + compression: Literal["deflate"] | None, + open_timeout: float | None, + close_timeout: float | None, ) -> Transport: try: from cdpify.transports.websocket import WebSocketTransport @@ -185,6 +233,9 @@ def _create_websocket_transport( url, additional_headers=additional_headers, max_frame_size=max_frame_size, - default_timeout=default_timeout, max_queue_size=max_queue_size, + overflow=overflow, + compression=compression, + open_timeout=open_timeout, + close_timeout=close_timeout, ) diff --git a/cdpify/codec.py b/cdpify/codec.py index 08af42b2..d126ff02 100644 --- a/cdpify/codec.py +++ b/cdpify/codec.py @@ -1,16 +1,21 @@ import logging import sys from collections.abc import Callable +from contextvars import ContextVar from dataclasses import MISSING, dataclass, fields, is_dataclass from enum import StrEnum from functools import cache from types import UnionType from typing import Any, TypeAliasType, Union, get_args, get_origin, get_type_hints +from cdpify.exceptions import CDPDecodeError +from cdpify.unset import UNSET + logger = logging.getLogger(__name__) _CDP_ENABLED_METADATA_KEY = "cdp" _CDP_NAME_METADATA_KEY = "cdp_name" +_strict_decoding: ContextVar[bool] = ContextVar("cdpify_strict_decoding", default=True) # Converts a non-None wire value; ``None`` means the value is used as is. @@ -25,6 +30,7 @@ class _FieldPlan: init: bool required: bool wire_enabled: bool + omit_none: bool @dataclass(frozen=True, slots=True) @@ -44,7 +50,7 @@ def encode_cdp(model: Any) -> dict[str, Any]: continue value = getattr(model, model_field.name) - if value is not None: + if value is not UNSET and (value is not None or not model_field.omit_none): encoded[model_field.cdp_name] = _encode_value(value) return encoded @@ -53,8 +59,23 @@ def encode_cdp(model: Any) -> dict[str, Any]: def decode_cdp[T]( model_type: type[T], data: dict[str, Any], + *, + strict: bool = True, ) -> T: - """Construct a generated CDP dataclass from its wire representation.""" + """Construct a CDP dataclass, rejecting missing required fields by default. + + ``strict=False`` fills missing required fields with None and logs a warning, + including in nested models. Extra wire fields are always ignored. This is + structural decoding, not validation of primitive values against a schema. + """ + token = _strict_decoding.set(strict) + try: + return _decode_model(model_type, data) + finally: + _strict_decoding.reset(token) + + +def _decode_model[T](model_type: type[T], data: dict[str, Any]) -> T: plan = _compile_model(model_type) values: dict[str, Any] = {} @@ -63,7 +84,10 @@ def decode_cdp[T]( if model_field is None: continue if value is not None and model_field.decoder is not None: - value = model_field.decoder(value) + try: + value = model_field.decoder(value) + except CDPDecodeError as error: + raise error._prepend(cdp_name) from error values[model_field.name] = value if not values.keys() >= plan.required_names: @@ -73,6 +97,8 @@ def decode_cdp[T]( if model_field.name in plan.required_names and model_field.name not in values ] + if _strict_decoding.get(): + raise CDPDecodeError(model_type, tuple(missing)) logger.warning( "CDP spec mismatch for %s. Missing fields: %s. Data keys: %s", model_type.__name__, @@ -101,6 +127,7 @@ def _compile_model(model_type: type) -> _ModelPlan: and model_field.default_factory is MISSING ), wire_enabled=model_field.metadata.get(_CDP_ENABLED_METADATA_KEY, True), + omit_none=not _is_any(resolved_types.get(model_field.name, Any)), ) for model_field in fields(model_type) ) @@ -150,6 +177,8 @@ def _resolve_field_types(model_type: type) -> dict[str, Any]: def _encode_value(value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value if is_dataclass(value) and not isinstance(value, type): return encode_cdp(value) if isinstance(value, (list, tuple)): @@ -159,6 +188,16 @@ def _encode_value(value: Any) -> Any: return value +def _is_any(field_type: Any) -> bool: + if field_type is Any: + return True + if isinstance(field_type, TypeAliasType): + return _is_any(field_type.__value__) + return get_origin(field_type) in (Union, UnionType) and any( + _is_any(arg) for arg in get_args(field_type) + ) + + def _build_decoder(field_type: Any) -> _Decoder: """Build a converter for one annotation once, instead of per value.""" if field_type is Any: @@ -224,7 +263,7 @@ def _enum_decoder(enum_type: type[StrEnum]) -> Callable[[str], StrEnum]: def _model_decoder(model_type: type) -> Callable[[Any], Any]: # Resolved lazily so recursive models (e.g. a StackTrace parent) work. def decode(value: Any) -> Any: - return decode_cdp(model_type, value) if isinstance(value, dict) else value + return _decode_model(model_type, value) if isinstance(value, dict) else value return decode diff --git a/cdpify/domains/accessibility/commands.py b/cdpify/domains/accessibility/commands.py index 7d9fbb83..2b94c27f 100644 --- a/cdpify/domains/accessibility/commands.py +++ b/cdpify/domains/accessibility/commands.py @@ -30,36 +30,61 @@ class AccessibilityCommand(StrEnum): class GetPartialAXTreeParams: """ Fetches the accessibility node and partial accessibility tree for this DOM node, if - it exists. + it exists. Experimental: this API may change or be removed. """ node_id: dom.NodeId | None = field(default=None, metadata={"cdp_name": "nodeId"}) + """ + Identifier of the node to get the partial accessibility tree for. + """ backend_node_id: dom.BackendNodeId | None = field( default=None, metadata={"cdp_name": "backendNodeId"} ) + """ + Identifier of the backend node to get the partial accessibility tree for. + """ object_id: runtime.RemoteObjectId | None = field( default=None, metadata={"cdp_name": "objectId"} ) + """ + JavaScript object id of the node wrapper to get the partial accessibility tree for. + """ fetch_relatives: bool | None = field( default=None, metadata={"cdp_name": "fetchRelatives"} ) + """ + Whether to fetch this node's ancestors, siblings and children. Defaults to true. + """ @dataclass(kw_only=True, slots=True) class GetPartialAXTreeResult: nodes: list[AXNode] + """ + The `Accessibility.AXNode` for this DOM node, if it exists, plus its ancestors, + siblings and children, if requested. + """ @dataclass(kw_only=True, slots=True) class GetFullAXTreeParams: """ - Fetches the entire accessibility tree for the root Document + Fetches the entire accessibility tree for the root Document Experimental: this API + may change or be removed. """ depth: int | None = None + """ + The maximum depth at which descendants of the root node should be retrieved. If + omitted, the full tree is returned. + """ frame_id: page.FrameId | None = field( default=None, metadata={"cdp_name": "frameId"} ) + """ + The frame for whose document the AX tree should be retrieved. If omitted, the root + frame is used. + """ @dataclass(kw_only=True, slots=True) @@ -71,11 +96,15 @@ class GetFullAXTreeResult: class GetRootAXNodeParams: """ Fetches the root node. Requires `enable()` to have been called previously. + Experimental: this API may change or be removed. """ frame_id: page.FrameId | None = field( default=None, metadata={"cdp_name": "frameId"} ) + """ + The frame in whose document the node resides. If omitted, the root frame is used. + """ @dataclass(kw_only=True, slots=True) @@ -87,16 +116,25 @@ class GetRootAXNodeResult: class GetAXNodeAndAncestorsParams: """ Fetches a node and all ancestors up to and including the root. Requires `enable()` - to have been called previously. + to have been called previously. Experimental: this API may change or be removed. """ node_id: dom.NodeId | None = field(default=None, metadata={"cdp_name": "nodeId"}) + """ + Identifier of the node to get. + """ backend_node_id: dom.BackendNodeId | None = field( default=None, metadata={"cdp_name": "backendNodeId"} ) + """ + Identifier of the backend node to get. + """ object_id: runtime.RemoteObjectId | None = field( default=None, metadata={"cdp_name": "objectId"} ) + """ + JavaScript object id of the node wrapper to get. + """ @dataclass(kw_only=True, slots=True) @@ -108,13 +146,16 @@ class GetAXNodeAndAncestorsResult: class GetChildAXNodesParams: """ Fetches a particular accessibility node by AXNodeId. Requires `enable()` to have - been called previously. + been called previously. Experimental: this API may change or be removed. """ id: AXNodeId frame_id: page.FrameId | None = field( default=None, metadata={"cdp_name": "frameId"} ) + """ + The frame in whose document the node resides. If omitted, the root frame is used. + """ @dataclass(kw_only=True, slots=True) @@ -130,22 +171,41 @@ class QueryAXTreeParams: ignored for accessibility, and returns those that match the specified name and role. If no DOM node is specified, or the DOM node does not exist, the command returns an error. If neither `accessibleName` or `role` is specified, it returns all the - accessibility nodes in the subtree. + accessibility nodes in the subtree. Experimental: this API may change or be removed. """ node_id: dom.NodeId | None = field(default=None, metadata={"cdp_name": "nodeId"}) + """ + Identifier of the node for the root to query. + """ backend_node_id: dom.BackendNodeId | None = field( default=None, metadata={"cdp_name": "backendNodeId"} ) + """ + Identifier of the backend node for the root to query. + """ object_id: runtime.RemoteObjectId | None = field( default=None, metadata={"cdp_name": "objectId"} ) + """ + JavaScript object id of the node wrapper for the root to query. + """ accessible_name: str | None = field( default=None, metadata={"cdp_name": "accessibleName"} ) + """ + Find nodes with this computed name. + """ role: str | None = None + """ + Find nodes with this computed role. + """ @dataclass(kw_only=True, slots=True) class QueryAXTreeResult: nodes: list[AXNode] + """ + A list of `Accessibility.AXNode` matching the specified attributes, including nodes + that are ignored for accessibility. + """ diff --git a/cdpify/domains/accessibility/domain.py b/cdpify/domains/accessibility/domain.py index 7456f17c..91c24a64 100644 --- a/cdpify/domains/accessibility/domain.py +++ b/cdpify/domains/accessibility/domain.py @@ -4,24 +4,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any -from cdpify.codec import decode_cdp, encode_cdp +from cdpify.codec import _encode_value, decode_cdp from cdpify.executor import CommandExecutor from .commands import ( AccessibilityCommand, - GetAXNodeAndAncestorsParams, GetAXNodeAndAncestorsResult, - GetChildAXNodesParams, GetChildAXNodesResult, - GetFullAXTreeParams, GetFullAXTreeResult, - GetPartialAXTreeParams, GetPartialAXTreeResult, - GetRootAXNodeParams, GetRootAXNodeResult, - QueryAXTreeParams, QueryAXTreeResult, ) from .types import ( @@ -70,18 +64,30 @@ async def get_partial_ax_tree( ) -> GetPartialAXTreeResult: """ Fetches the accessibility node and partial accessibility tree for this DOM - node, if it exists. - """ - params = GetPartialAXTreeParams( - node_id=node_id, - backend_node_id=backend_node_id, - object_id=object_id, - fetch_relatives=fetch_relatives, - ) + node, if it exists. Experimental: this API may change or be removed. + + Args: + node_id: Identifier of the node to get the partial accessibility tree for. + backend_node_id: Identifier of the backend node to get the partial + accessibility tree for. + object_id: JavaScript object id of the node wrapper to get the partial + accessibility tree for. + fetch_relatives: Whether to fetch this node's ancestors, siblings and + children. Defaults to true. + """ + _params: dict[str, Any] = {} + if node_id is not None: + _params["nodeId"] = _encode_value(node_id) + if backend_node_id is not None: + _params["backendNodeId"] = _encode_value(backend_node_id) + if object_id is not None: + _params["objectId"] = _encode_value(object_id) + if fetch_relatives is not None: + _params["fetchRelatives"] = fetch_relatives result = await self._executor.execute( method=AccessibilityCommand.GET_PARTIAL_AX_TREE, - params=encode_cdp(params), + params=_params, ) return decode_cdp(GetPartialAXTreeResult, result) @@ -92,13 +98,24 @@ async def get_full_ax_tree( frame_id: page.FrameId | None = None, ) -> GetFullAXTreeResult: """ - Fetches the entire accessibility tree for the root Document + Fetches the entire accessibility tree for the root Document Experimental: this + API may change or be removed. + + Args: + depth: The maximum depth at which descendants of the root node should be + retrieved. If omitted, the full tree is returned. + frame_id: The frame for whose document the AX tree should be retrieved. If + omitted, the root frame is used. """ - params = GetFullAXTreeParams(depth=depth, frame_id=frame_id) + _params: dict[str, Any] = {} + if depth is not None: + _params["depth"] = depth + if frame_id is not None: + _params["frameId"] = _encode_value(frame_id) result = await self._executor.execute( method=AccessibilityCommand.GET_FULL_AX_TREE, - params=encode_cdp(params), + params=_params, ) return decode_cdp(GetFullAXTreeResult, result) @@ -109,12 +126,19 @@ async def get_root_ax_node( ) -> GetRootAXNodeResult: """ Fetches the root node. Requires `enable()` to have been called previously. + Experimental: this API may change or be removed. + + Args: + frame_id: The frame in whose document the node resides. If omitted, the root + frame is used. """ - params = GetRootAXNodeParams(frame_id=frame_id) + _params: dict[str, Any] = {} + if frame_id is not None: + _params["frameId"] = _encode_value(frame_id) result = await self._executor.execute( method=AccessibilityCommand.GET_ROOT_AX_NODE, - params=encode_cdp(params), + params=_params, ) return decode_cdp(GetRootAXNodeResult, result) @@ -127,15 +151,25 @@ async def get_ax_node_and_ancestors( ) -> GetAXNodeAndAncestorsResult: """ Fetches a node and all ancestors up to and including the root. Requires - `enable()` to have been called previously. - """ - params = GetAXNodeAndAncestorsParams( - node_id=node_id, backend_node_id=backend_node_id, object_id=object_id - ) + `enable()` to have been called previously. Experimental: this API may change or + be removed. + + Args: + node_id: Identifier of the node to get. + backend_node_id: Identifier of the backend node to get. + object_id: JavaScript object id of the node wrapper to get. + """ + _params: dict[str, Any] = {} + if node_id is not None: + _params["nodeId"] = _encode_value(node_id) + if backend_node_id is not None: + _params["backendNodeId"] = _encode_value(backend_node_id) + if object_id is not None: + _params["objectId"] = _encode_value(object_id) result = await self._executor.execute( method=AccessibilityCommand.GET_AX_NODE_AND_ANCESTORS, - params=encode_cdp(params), + params=_params, ) return decode_cdp(GetAXNodeAndAncestorsResult, result) @@ -147,13 +181,20 @@ async def get_child_ax_nodes( ) -> GetChildAXNodesResult: """ Fetches a particular accessibility node by AXNodeId. Requires `enable()` to - have been called previously. + have been called previously. Experimental: this API may change or be removed. + + Args: + frame_id: The frame in whose document the node resides. If omitted, the root + frame is used. """ - params = GetChildAXNodesParams(id=id, frame_id=frame_id) + _params: dict[str, Any] = {} + _params["id"] = _encode_value(id) + if frame_id is not None: + _params["frameId"] = _encode_value(frame_id) result = await self._executor.execute( method=AccessibilityCommand.GET_CHILD_AX_NODES, - params=encode_cdp(params), + params=_params, ) return decode_cdp(GetChildAXNodesResult, result) @@ -172,18 +213,30 @@ async def query_ax_tree( that are ignored for accessibility, and returns those that match the specified name and role. If no DOM node is specified, or the DOM node does not exist, the command returns an error. If neither `accessibleName` or `role` is specified, it - returns all the accessibility nodes in the subtree. - """ - params = QueryAXTreeParams( - node_id=node_id, - backend_node_id=backend_node_id, - object_id=object_id, - accessible_name=accessible_name, - role=role, - ) + returns all the accessibility nodes in the subtree. Experimental: this API may + change or be removed. + + Args: + node_id: Identifier of the node for the root to query. + backend_node_id: Identifier of the backend node for the root to query. + object_id: JavaScript object id of the node wrapper for the root to query. + accessible_name: Find nodes with this computed name. + role: Find nodes with this computed role. + """ + _params: dict[str, Any] = {} + if node_id is not None: + _params["nodeId"] = _encode_value(node_id) + if backend_node_id is not None: + _params["backendNodeId"] = _encode_value(backend_node_id) + if object_id is not None: + _params["objectId"] = _encode_value(object_id) + if accessible_name is not None: + _params["accessibleName"] = accessible_name + if role is not None: + _params["role"] = role result = await self._executor.execute( method=AccessibilityCommand.QUERY_AX_TREE, - params=encode_cdp(params), + params=_params, ) return decode_cdp(QueryAXTreeResult, result) diff --git a/cdpify/domains/accessibility/events.py b/cdpify/domains/accessibility/events.py index 804c1a7a..311871c0 100644 --- a/cdpify/domains/accessibility/events.py +++ b/cdpify/domains/accessibility/events.py @@ -22,21 +22,28 @@ class AccessibilityEvent(StrEnum): class LoadCompleteEvent: """ The loadComplete event mirrors the load complete event sent by the browser to - assistive technology when the web page has finished loading. + assistive technology when the web page has finished loading. Experimental: this API + may change or be removed. """ __cdp_event__: ClassVar[AccessibilityEvent] = AccessibilityEvent.LOAD_COMPLETE root: AXNode + """ + New document root node. + """ @dataclass(kw_only=True, slots=True) class NodesUpdatedEvent: """ The nodesUpdated event is sent every time a previously requested node has changed - the in tree. + the in tree. Experimental: this API may change or be removed. """ __cdp_event__: ClassVar[AccessibilityEvent] = AccessibilityEvent.NODES_UPDATED nodes: list[AXNode] + """ + Updated node data. + """ diff --git a/cdpify/domains/accessibility/types.py b/cdpify/domains/accessibility/types.py index 26444865..2d9091b0 100644 --- a/cdpify/domains/accessibility/types.py +++ b/cdpify/domains/accessibility/types.py @@ -9,6 +9,7 @@ from cdpify.domains import dom, page from cdpify.shared.enums import CDPStrEnum +from cdpify.unset import UNSET, UnsetType type AXNodeId = str """ @@ -78,22 +79,49 @@ class AXValueSource: """ type: AXValueSourceType + """ + What type of source this is. + """ value: AXValue | None = None + """ + The value of this property source. + """ attribute: str | None = None + """ + The name of the relevant attribute, if any. + """ attribute_value: AXValue | None = field( default=None, metadata={"cdp_name": "attributeValue"} ) + """ + The value of the relevant attribute, if any. + """ superseded: bool | None = None + """ + Whether this source is superseded by a higher priority source. + """ native_source: AXValueNativeSourceType | None = field( default=None, metadata={"cdp_name": "nativeSource"} ) + """ + The native markup source for this value, e.g. a `