Skip to content

CDDL 2 Python generator#16914

Open
AutomatedTester wants to merge 31 commits intotrunkfrom
cddl2py
Open

CDDL 2 Python generator#16914
AutomatedTester wants to merge 31 commits intotrunkfrom
cddl2py

Conversation

@AutomatedTester
Copy link
Copy Markdown
Member

@AutomatedTester AutomatedTester commented Jan 16, 2026

This generates bidi code based off of the CDDL that we can update from the specification. I expect over time the generation will need other features added.

@selenium-ci selenium-ci added the C-py Python Bindings label Jan 16, 2026
@AutomatedTester AutomatedTester marked this pull request as draft January 16, 2026 10:14
Copilot AI review requested due to automatic review settings February 16, 2026 11:44
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a CDDL (Concise Data Definition Language) to Python generator for WebDriver BiDi modules. It generates 9 BiDi protocol modules from the W3C specification, replacing hand-written implementations with auto-generated code.

Changes:

  • Adds py/generate_bidi.py - CDDL parser and Python code generator (623 lines)
  • Adds Bazel build integration for code generation
  • Generates 9 BiDi modules (browser, browsing_context, emulation, input, network, script, session, storage, webextension) with 146 type definitions and 52 commands
  • Adds validation tooling and documentation

Reviewed changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
py/generate_bidi.py Core CDDL parser and Python code generator
py/private/generate_bidi.bzl Bazel rule for BiDi code generation
py/BUILD.bazel Integration of generation target
py/requirements.txt Added pycddl dependency
py/selenium/webdriver/common/bidi/*.py Generated BiDi module replacements
py/validate_bidi_modules.py Validation tooling for comparing generated vs hand-written code
common/bidi/spec/local.cddl CDDL specification (1331 lines)
Various .md files Documentation and findings

Copilot AI review requested due to automatic review settings February 17, 2026 10:58
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 24 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

py/validate_bidi_modules.py:1

  • Corrected spelling of 'Analyze' to match class name convention.
#!/usr/bin/env python3

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 33 out of 34 changed files in this pull request and generated 10 comments.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 35 out of 36 changed files in this pull request and generated 10 comments.

Copilot AI review requested due to automatic review settings February 25, 2026 13:09
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 35 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

py/selenium/webdriver/remote/webdriver.py:1107

  • start_devtools()/bidi_connection() call import_cdp() which imports selenium.webdriver.common.bidi.cdp, but this PR deletes py/selenium/webdriver/common/bidi/cdp.py. That will cause a ModuleNotFoundError the first time devtools/BiDi connection code runs. Either keep/replace the cdp module (and update import_cdp() accordingly) or remove these code paths.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 32 out of 34 changed files in this pull request and generated 8 comments.

Copilot AI review requested due to automatic review settings March 2, 2026 11:27
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 33 out of 34 changed files in this pull request and generated 1 comment.

Copilot AI review requested due to automatic review settings March 7, 2026 11:48
Copilot AI review requested due to automatic review settings April 7, 2026 11:14
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 37 out of 38 changed files in this pull request and generated 8 comments.

@dataclass
class SetTouchOverrideParameters:
"""SetTouchOverrideParameters."""

Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SetTouchOverrideParameters is missing the required maxTouchPoints/max_touch_points field from the BiDi spec, so callers cannot set or clear the touch override value (only contexts/userContexts can be provided). Add the missing field and thread it through Emulation.set_touch_override so the command can send maxTouchPoints (including null to clear).

Suggested change
max_touch_points: Any | None = None

Copilot uses AI. Check for mistakes.
Comment on lines +203 to +211
if theme is None:
raise TypeError("set_forced_colors_mode_theme_override() missing required argument: 'theme'")

params = {
"theme": theme,
"contexts": contexts,
"userContexts": user_contexts,
}
params = {k: v for k, v in params.items() if v is not None}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The BiDi spec defines theme as ForcedColorsModeTheme / null (required key, nullable). This implementation raises when theme is None and also filters out None values, so callers cannot clear the override by sending JSON null. Allow theme=None and ensure the payload always includes the theme key (similar pattern to browser.set_download_behavior).

Suggested change
if theme is None:
raise TypeError("set_forced_colors_mode_theme_override() missing required argument: 'theme'")
params = {
"theme": theme,
"contexts": contexts,
"userContexts": user_contexts,
}
params = {k: v for k, v in params.items() if v is not None}
params = {
"theme": theme,
}
if contexts is not None:
params["contexts"] = contexts
if user_contexts is not None:
params["userContexts"] = user_contexts

Copilot uses AI. Check for mistakes.
Comment on lines +223 to +231
if locale is None:
raise TypeError("set_locale_override() missing required argument: 'locale'")

params = {
"locale": locale,
"contexts": contexts,
"userContexts": user_contexts,
}
params = {k: v for k, v in params.items() if v is not None}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

locale is text / null in the BiDi spec (required key, nullable), but this method treats None as a missing argument and filters it out. This prevents clearing the locale override. Accept locale=None and always include the locale key in params (send JSON null) instead of omitting it.

Suggested change
if locale is None:
raise TypeError("set_locale_override() missing required argument: 'locale'")
params = {
"locale": locale,
"contexts": contexts,
"userContexts": user_contexts,
}
params = {k: v for k, v in params.items() if v is not None}
params = {
"locale": locale,
}
if contexts is not None:
params["contexts"] = contexts
if user_contexts is not None:
params["userContexts"] = user_contexts

Copilot uses AI. Check for mistakes.
Comment on lines +176 to 183
class ClientWindowNamedState:
"""Named states for a browser client window."""

FULLSCREEN = "fullscreen"
MAXIMIZED = "maximized"
MINIMIZED = "minimized"
NORMAL = "normal"

Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ClientWindowNamedState includes NORMAL, but the BiDi spec only allows named states of fullscreen/maximized/minimized; normal is represented via the rect-state object. Exposing NORMAL here is misleading and will encourage sending an invalid named state.

Copilot uses AI. Check for mistakes.
Comment on lines +345 to +357
# Serialize ClientWindowRectState if needed
state_param = state
if hasattr(state, '__dataclass_fields__'):
# It's a dataclass, convert to dict
state_param = {
k: v for k, v in state.__dict__.items()
if v is not None
}

params = {
"clientWindow": client_window,
"state": state_param,
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

browser.setClientWindowState parameters are a union that is flattened at the top level (e.g. {clientWindow, state, width, height, x, y}), but this implementation nests dict/dataclass state under a state key (e.g. {"state": {"state": "normal", ...}}). For rect-state updates, merge the state mapping into params instead of nesting it.

Suggested change
# Serialize ClientWindowRectState if needed
state_param = state
if hasattr(state, '__dataclass_fields__'):
# It's a dataclass, convert to dict
state_param = {
k: v for k, v in state.__dict__.items()
if v is not None
}
params = {
"clientWindow": client_window,
"state": state_param,
}
params = {
"clientWindow": client_window,
}
# Serialize ClientWindowRectState if needed. Rect-state updates are a
# flattened union in the BiDi protocol and must be merged at the top level.
if hasattr(state, '__dataclass_fields__'):
state = {
k: v for k, v in state.__dict__.items()
if v is not None
}
if isinstance(state, dict):
params.update({k: v for k, v in state.items() if v is not None})
else:
params["state"] = state

Copilot uses AI. Check for mistakes.
Comment on lines +667 to +733
"""Execute network.beforeRequestSent."""
if method is None:
raise TypeError("before_request_sent() missing required argument: 'method'")
if params is None:
raise TypeError("before_request_sent() missing required argument: 'params'")

params = {
"initiator": initiator,
"method": method,
"params": params,
}
params = {k: v for k, v in params.items() if v is not None}
cmd = command_builder("network.beforeRequestSent", params)
result = self._conn.execute(cmd)
return result

def fetch_error(self, error_text: Any | None = None, method: Any | None = None, params: Any | None = None):
"""Execute network.fetchError."""
if error_text is None:
raise TypeError("fetch_error() missing required argument: 'error_text'")
if method is None:
raise TypeError("fetch_error() missing required argument: 'method'")
if params is None:
raise TypeError("fetch_error() missing required argument: 'params'")

params = {
"errorText": error_text,
"method": method,
"params": params,
}
params = {k: v for k, v in params.items() if v is not None}
cmd = command_builder("network.fetchError", params)
result = self._conn.execute(cmd)
return result

def response_completed(self, response: Any | None = None, method: Any | None = None, params: Any | None = None):
"""Execute network.responseCompleted."""
if response is None:
raise TypeError("response_completed() missing required argument: 'response'")
if method is None:
raise TypeError("response_completed() missing required argument: 'method'")
if params is None:
raise TypeError("response_completed() missing required argument: 'params'")

params = {
"response": response,
"method": method,
"params": params,
}
params = {k: v for k, v in params.items() if v is not None}
cmd = command_builder("network.responseCompleted", params)
result = self._conn.execute(cmd)
return result

def response_started(self, response: Any | None = None):
"""Execute network.responseStarted."""
if response is None:
raise TypeError("response_started() missing required argument: 'response'")

params = {
"response": response,
}
params = {k: v for k, v in params.items() if v is not None}
cmd = command_builder("network.responseStarted", params)
result = self._conn.execute(cmd)
return result

Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These methods appear to send BiDi events (e.g. network.beforeRequestSent, network.fetchError, network.responseCompleted) as if they were commands via command_builder. In the spec these are event names, not commands, so calling them will fail with "unknown command". Consider removing these "Execute network.*" methods and exposing event subscription/handler APIs instead.

Suggested change
"""Execute network.beforeRequestSent."""
if method is None:
raise TypeError("before_request_sent() missing required argument: 'method'")
if params is None:
raise TypeError("before_request_sent() missing required argument: 'params'")
params = {
"initiator": initiator,
"method": method,
"params": params,
}
params = {k: v for k, v in params.items() if v is not None}
cmd = command_builder("network.beforeRequestSent", params)
result = self._conn.execute(cmd)
return result
def fetch_error(self, error_text: Any | None = None, method: Any | None = None, params: Any | None = None):
"""Execute network.fetchError."""
if error_text is None:
raise TypeError("fetch_error() missing required argument: 'error_text'")
if method is None:
raise TypeError("fetch_error() missing required argument: 'method'")
if params is None:
raise TypeError("fetch_error() missing required argument: 'params'")
params = {
"errorText": error_text,
"method": method,
"params": params,
}
params = {k: v for k, v in params.items() if v is not None}
cmd = command_builder("network.fetchError", params)
result = self._conn.execute(cmd)
return result
def response_completed(self, response: Any | None = None, method: Any | None = None, params: Any | None = None):
"""Execute network.responseCompleted."""
if response is None:
raise TypeError("response_completed() missing required argument: 'response'")
if method is None:
raise TypeError("response_completed() missing required argument: 'method'")
if params is None:
raise TypeError("response_completed() missing required argument: 'params'")
params = {
"response": response,
"method": method,
"params": params,
}
params = {k: v for k, v in params.items() if v is not None}
cmd = command_builder("network.responseCompleted", params)
result = self._conn.execute(cmd)
return result
def response_started(self, response: Any | None = None):
"""Execute network.responseStarted."""
if response is None:
raise TypeError("response_started() missing required argument: 'response'")
params = {
"response": response,
}
params = {k: v for k, v in params.items() if v is not None}
cmd = command_builder("network.responseStarted", params)
result = self._conn.execute(cmd)
return result
"""network.beforeRequestSent is a BiDi event, not a command."""
raise NotImplementedError(
"network.beforeRequestSent is a BiDi event and cannot be executed as a command; "
"use the BiDi event subscription/handler APIs to listen for this event."
)
def fetch_error(self, error_text: Any | None = None, method: Any | None = None, params: Any | None = None):
"""network.fetchError is a BiDi event, not a command."""
raise NotImplementedError(
"network.fetchError is a BiDi event and cannot be executed as a command; "
"use the BiDi event subscription/handler APIs to listen for this event."
)
def response_completed(self, response: Any | None = None, method: Any | None = None, params: Any | None = None):
"""network.responseCompleted is a BiDi event, not a command."""
raise NotImplementedError(
"network.responseCompleted is a BiDi event and cannot be executed as a command; "
"use the BiDi event subscription/handler APIs to listen for this event."
)
def response_started(self, response: Any | None = None):
"""network.responseStarted is a BiDi event, not a command."""
raise NotImplementedError(
"network.responseStarted is a BiDi event and cannot be executed as a command; "
"use the BiDi event subscription/handler APIs to listen for this event."
)

Copilot uses AI. Check for mistakes.
Comment on lines +287 to +312
def set_cookie(self, cookie=None, partition=None):
"""Execute storage.setCookie."""
if cookie and hasattr(cookie, "to_bidi_dict"):
cookie = cookie.to_bidi_dict()
if partition and hasattr(partition, "to_bidi_dict"):
partition = partition.to_bidi_dict()
params = {
"cookie": cookie,
"partition": partition,
}
params = {k: v for k, v in params.items() if v is not None}
cmd = command_builder("storage.setCookie", params)
result = self._conn.execute(cmd)
if isinstance(result, dict):
pk_raw = result.get("partitionKey")
pk = (
PartitionKey(
user_context=pk_raw.get("userContext"),
source_origin=pk_raw.get("sourceOrigin"),
)
if isinstance(pk_raw, dict)
else None
)
"""
params = {}
if filter is not None:
params["filter"] = filter.to_dict()
if partition is not None:
params["partition"] = partition.to_dict()

result = self.conn.execute(command_builder("storage.getCookies", params))
return GetCookiesResult.from_dict(result)

def set_cookie(
self,
cookie: PartialCookie,
partition: BrowsingContextPartitionDescriptor | StorageKeyPartitionDescriptor | None = None,
) -> SetCookieResult:
"""Sets a cookie in the browser.

Args:
cookie: The cookie to set.
partition: Optional partition descriptor.

Returns:
The result of the set cookie command.
"""
params = {"cookie": cookie.to_dict()}
if partition is not None:
params["partition"] = partition.to_dict()

result = self.conn.execute(command_builder("storage.setCookie", params))
return SetCookieResult.from_dict(result)

def delete_cookies(
self,
filter: CookieFilter | None = None,
partition: BrowsingContextPartitionDescriptor | StorageKeyPartitionDescriptor | None = None,
) -> DeleteCookiesResult:
"""Deletes cookies that match the given parameters.

Args:
filter: Optional filter to match cookies to delete.
partition: Optional partition descriptor.

Returns:
The result of the delete cookies command.
"""
params = {}
if filter is not None:
params["filter"] = filter.to_dict()
if partition is not None:
params["partition"] = partition.to_dict()

result = self.conn.execute(command_builder("storage.deleteCookies", params))
return DeleteCookiesResult.from_dict(result)
return SetCookieResult(partition_key=pk)
return result
def delete_cookies(self, filter=None, partition=None):
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

set_cookie (and similarly delete_cookies) returns a typed SetCookieResult/DeleteCookiesResult only when the response is a dict, otherwise it returns the raw value. This makes the public API's return type inconsistent and hard to use. Pick a single return type (always the dataclass wrapper or always the raw dict) and stick to it.

Copilot uses AI. Check for mistakes.
Comment on lines 102 to +107
def test_client_window_state_constants(driver):
assert ClientWindowState.FULLSCREEN == "fullscreen"
assert ClientWindowState.MAXIMIZED == "maximized"
assert ClientWindowState.MINIMIZED == "minimized"
assert ClientWindowState.NORMAL == "normal"
"""Test ClientWindowNamedState constants."""
assert ClientWindowNamedState.FULLSCREEN == "fullscreen"
assert ClientWindowNamedState.MAXIMIZED == "maximized"
assert ClientWindowNamedState.MINIMIZED == "minimized"
assert ClientWindowNamedState.NORMAL == "normal"
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The BiDi spec models normal as the rect-state object, not a named state. This test (and the exported constants) currently treat NORMAL as a named state constant. Update the assertions to match the spec (named states: fullscreen/maximized/minimized) and validate the rect-state representation separately if needed.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings April 8, 2026 10:15
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 37 out of 38 changed files in this pull request and generated 2 comments.

Comment on lines +413 to +428
assert device_pixel_ratio == default_device_pixel_ratio
# Allow some tolerance since some window managers might not put it to the exact value
assert abs(viewport_size[0] - default_viewport_size[0]) <= 5
assert abs(viewport_size[1] - default_viewport_size[1]) <= 5
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

device_pixel_ratio and default_device_pixel_ratio are now unused after removing the DPR assertion, which will trigger Ruff's unused-variable check. Also, the test docstring says it resets both viewport and device pixel ratio, but the DPR reset is no longer verified. Either reintroduce an assertion for devicePixelRatio (with appropriate tolerance if needed) or remove the DPR variables and update the docstring to match what’s being tested.

Copilot uses AI. Check for mistakes.
Comment on lines +765 to +799
def add_request_handler(self, event, callback, url_patterns=None):
"""Add a handler for network requests at the specified phase.

Args:
username: The username to authenticate with.
password: The password to authenticate with.
event: Event name, e.g. ``"before_request"``.
callback: Callable receiving a :class:`Request` instance.
url_patterns: optional list of URL pattern dicts to filter.

Returns:
int: callback id
callback_id int for later removal via remove_request_handler.
"""
event = "auth_required"

def _callback(request: Request) -> None:
request._continue_with_auth(username, password)
phase_map = {
"before_request": "beforeRequestSent",
"before_request_sent": "beforeRequestSent",
"response_started": "responseStarted",
"auth_required": "authRequired",
}
phase = phase_map.get(event, "beforeRequestSent")
intercept_result = self._add_intercept(phases=[phase], url_patterns=url_patterns)
intercept_id = intercept_result.get("intercept") if intercept_result else None

def _request_callback(params):
raw = (
params
if isinstance(params, dict)
else (params.__dict__ if hasattr(params, "__dict__") else {})
)
request = Request(self._conn, raw)
callback(request)

return self.add_request_handler(event, _callback)
callback_id = self.add_event_handler(event, _request_callback)
if intercept_id:
self._handler_intercepts[callback_id] = intercept_id
return callback_id
def remove_request_handler(self, event, callback_id):
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add_request_handler accepts events like before_request_sent and response_started, but Network.EVENT_CONFIGS only registers auth_required and before_request. As written, self.add_event_handler(event, ...) will raise ValueError for unsupported event keys, so some documented event names cannot work. Either constrain/validate event to the supported keys, or map aliases (e.g., before_request_sent -> before_request) before calling add_event_handler, and expand EVENT_CONFIGS if you intend to support more events.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

C-py Python Bindings

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants