Conversation
9947809 to
38e222b
Compare
38e222b to
79aa226
Compare
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
c7311e8 to
8ea2d91
Compare
8ea2d91 to
1fed8ae
Compare
There was a problem hiding this comment.
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()callimport_cdp()which importsselenium.webdriver.common.bidi.cdp, but this PR deletespy/selenium/webdriver/common/bidi/cdp.py. That will cause aModuleNotFoundErrorthe first time devtools/BiDi connection code runs. Either keep/replace thecdpmodule (and updateimport_cdp()accordingly) or remove these code paths.
769bc91 to
3ee0804
Compare
… the window to the size we want
| @dataclass | ||
| class SetTouchOverrideParameters: | ||
| """SetTouchOverrideParameters.""" | ||
|
|
There was a problem hiding this comment.
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).
| max_touch_points: Any | None = None |
| 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} |
There was a problem hiding this comment.
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).
| 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 |
| 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} |
There was a problem hiding this comment.
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.
| 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 |
| class ClientWindowNamedState: | ||
| """Named states for a browser client window.""" | ||
|
|
||
| FULLSCREEN = "fullscreen" | ||
| MAXIMIZED = "maximized" | ||
| MINIMIZED = "minimized" | ||
| NORMAL = "normal" | ||
|
|
There was a problem hiding this comment.
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.
| # 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, | ||
| } |
There was a problem hiding this comment.
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.
| # 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 |
| """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 | ||
|
|
There was a problem hiding this comment.
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.
| """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." | |
| ) |
| 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): |
There was a problem hiding this comment.
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.
| 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" |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| 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): |
There was a problem hiding this comment.
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.
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.