Skip to content
Merged
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
4 changes: 4 additions & 0 deletions py/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -789,11 +789,14 @@ generate_bidi_protocol(

# Build-graph tool: the generator invoked by the rule above (schema + output dir
# passed as arguments). No baked args, so it is reusable in the exec configuration.
# Depends on :remote because rendering reads errorhandler's error-code tables to decide
# which BiDi codes reuse a classic exception rather than declaring a new one.
py_binary(
name = "generate-bidi-protocol-tool",
srcs = ["generate_bidi_protocol.py"],
main = "generate_bidi_protocol.py",
visibility = ["//visibility:private"],
deps = [":remote"],
)

# `bazel run //py:generate-bidi-protocol` regenerates the checked-out tree for local
Expand All @@ -805,6 +808,7 @@ py_binary(
data = ["//javascript/selenium-webdriver:create-bidi-src_schema"],
main = "generate_bidi_protocol.py",
visibility = ["//visibility:public"],
deps = [":remote"],
)

py_library(
Expand Down
85 changes: 83 additions & 2 deletions py/generate_bidi_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -1049,15 +1049,96 @@ def render_module(mod: ModuleIR) -> str:
return "\n\n\n".join(blocks) + "\n"


def exception_class_name(code: str) -> str:
"""The Python exception class name for a BiDi wire error code."""
return "".join(word.capitalize() for word in re.split(r"[^a-zA-Z0-9]+", code) if word) + "Exception"


_ERRORS_DOCSTRING = '''"""Exception classes for BiDi wire error codes.

Codes the classic WebDriver error handler already types keep that class, so
``except NoSuchElementException`` catches a BiDi failure and a classic one alike, even
where the classic name does not follow from the wire code (``"no such alert"`` is
``NoAlertPresentException``, ``"unable to capture screen"`` is ``ScreenshotException``).
The rest are declared here as ``WebDriverException`` subclasses.

This is internal, unsupported implementation. See
https://www.selenium.dev/documentation/warnings/bidi-implementation/
"""'''


def _classic_exceptions() -> dict[str, type]:
"""Wire code to the classic exception the error handler already raises for it.

Read from the handler's own tables rather than a second copy of them, so a class it
retypes later follows here on the next build. A code the handler resolves to bare
``WebDriverException`` counts as untyped: a subclass declared in the generated module
is strictly more specific and still caught by anyone catching the base.
"""
from selenium.common.exceptions import WebDriverException
from selenium.webdriver.remote.errorhandler import ErrorCode, ExceptionMapping

classic: dict[str, type] = {}
for name in dir(ErrorCode):
codes = getattr(ErrorCode, name)
mapped = getattr(ExceptionMapping, name, None)
if not isinstance(codes, list) or mapped is None or mapped is WebDriverException:
continue
for code in codes:
if isinstance(code, str):
classic.setdefault(code, mapped)
return classic


def render_errors(schema: Schema) -> str:
"""Render the exception classes for the BiDi ``ErrorCode`` enum.

Emitted as real class statements rather than synthesized at import, so the classes
type-check, autocomplete and document like every other exception in the bindings.
"""
classic = _classic_exceptions()
resolved = {code: classic.get(code) for code in schema.types["ErrorCode"]["values"]}

imported = sorted({exc.__name__ for exc in resolved.values() if exc is not None} | {"WebDriverException"})
import_block = "\n".join(["from selenium.common.exceptions import (", *(f" {n}," for n in imported), ")"])

declarations = [
f'class {exception_class_name(code)}(WebDriverException):\n """Raised for the BiDi {lit(code)} error."""'
for code, exc in resolved.items()
if exc is None
]

entries = [
f" {lit(code)}: {exc.__name__ if exc is not None else exception_class_name(code)},"
for code, exc in resolved.items()
]
table = "\n".join(["EXCEPTIONS: dict[str, type[WebDriverException]] = {", *entries, "}"])

lookup = '''def exception_for(code: str | None) -> type[WebDriverException]:
"""The exception class for a wire error code, falling back for an unrecognized one.

An error the remote end reports must surface as that error even when the code is one
this schema does not declare, so an unknown code is never a serialization failure.
"""
return EXCEPTIONS.get(code, WebDriverException) if code else WebDriverException'''

blocks = [_HEADER, _ERRORS_DOCSTRING, "from __future__ import annotations", import_block]
blocks += declarations
blocks += [table, lookup]
return "\n\n\n".join(blocks) + "\n"


def render_all(schema_path: str) -> dict[str, str]:
"""Render every domain module; returns {filename: contents}.
"""Render every domain module plus the error classes; returns {filename: contents}.

The package ``__init__.py`` is hand-written (it carries only the package
docstring), so it is intentionally not emitted here.
"""
schema = Schema(json.loads(Path(schema_path).read_text(encoding="utf-8")))
modules = [build_module(schema, domain) for domain in schema.domains()]
return {f"{mod.filename}.py": render_module(mod) for mod in modules}
rendered = {f"{mod.filename}.py": render_module(mod) for mod in modules}
rendered["errors.py"] = render_errors(schema)
return rendered


def main() -> None:
Expand Down
6 changes: 4 additions & 2 deletions py/private/generate_bidi_protocol.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ already-projected, binding-neutral schema JSON and emits the generated domain mo
The hand-written runtime (``serialization``/``transport``/``domain``) is not produced here.
"""

# Generated domain modules, snake_case (one per BiDi domain in the schema). The
# package ``__init__.py`` is hand-written and checked in, so it is not generated here.
# Generated domain modules, snake_case (one per BiDi domain in the schema), plus the
# domain-less error classes. The package ``__init__.py`` is hand-written and checked
# in, so it is not generated here.
_MODULES = [
"errors",
"bluetooth",
"browser",
"browsing_context",
Expand Down
14 changes: 11 additions & 3 deletions py/selenium/webdriver/common/_bidi/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

from typing import Any

from selenium.common.exceptions import WebDriverException
from selenium.webdriver.common._bidi.errors import exception_for


class Transport:
Expand All @@ -52,7 +52,15 @@ def connection(self) -> Any:
def execute(self, cmd: str, params: Any = None, result: Any = None) -> Any:
reply = self._connection.send_cmd(cmd, params.as_json() if params is not None else {})
if "error" in reply:
message = reply.get("message")
raise WebDriverException(f"{reply['error']}: {message}" if message else reply["error"])
raise self._error(reply)
value = reply["result"]
return result.from_json(value) if result is not None else value

@staticmethod
def _error(reply: dict) -> Exception:
code = reply["error"]
# The class carries the code, so the message need not repeat it β€” except where the
# remote sent no message, which would otherwise leave nothing to read.
message = reply.get("message") or code
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
stacktrace = reply.get("stacktrace")
return exception_for(code)(message, stacktrace=stacktrace.split("\n") if stacktrace else None)
41 changes: 37 additions & 4 deletions py/test/unit/selenium/webdriver/common/bidi_transport_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@

import pytest

from selenium.common.exceptions import WebDriverException
from selenium.common.exceptions import NoSuchFrameException, WebDriverException
from selenium.webdriver.common._bidi.domain import Domain
from selenium.webdriver.common._bidi.errors import NoSuchUserContextException
from selenium.webdriver.common._bidi.serialization import Record, meta, register
from selenium.webdriver.common._bidi.transport import Transport

Expand All @@ -51,13 +52,14 @@ class DrivingConnection:

Records the outbound frame and returns a canned reply envelope, so the seam is
tested against the real send/reply contract rather than a mock of it. Set
``error`` (and optionally ``message``) to return an error envelope instead.
``error`` (and optionally ``message``/``stacktrace``) to return an error envelope instead.
"""

def __init__(self, reply=None, error=None, message=None):
def __init__(self, reply=None, error=None, message=None, stacktrace=None):
self.reply = reply
self.error = error
self.message = message
self.stacktrace = stacktrace
self.sent = None

def send_cmd(self, method, params):
Expand All @@ -66,6 +68,8 @@ def send_cmd(self, method, params):
envelope = {"error": self.error}
if self.message is not None:
envelope["message"] = self.message
if self.stacktrace is not None:
envelope["stacktrace"] = self.stacktrace
return envelope
return {"result": self.reply}

Expand Down Expand Up @@ -117,7 +121,7 @@ def test_execute_with_no_result_type_returns_the_raw_reply():
def test_execute_raises_when_the_reply_carries_an_error():
connection = DrivingConnection(error="unknown command", message="no such command")

with pytest.raises(WebDriverException, match=r"unknown command: no such command"):
with pytest.raises(WebDriverException, match=r"no such command"):
Transport(connection).execute("bad.command", params=Params(context="c"), result=Result)


Expand All @@ -129,6 +133,35 @@ def test_execute_error_without_a_message_raises_with_just_the_error():
assert exc_info.value.msg == "unknown command"


def test_a_shared_error_code_raises_the_classic_exception():
connection = DrivingConnection(error="no such frame", message="it is gone")

with pytest.raises(NoSuchFrameException, match=r"it is gone"):
Transport(connection).execute("bad.command", result=Result)


def test_a_bidi_only_error_code_raises_a_bidi_specific_exception():
connection = DrivingConnection(error="no such user context", message="nope")

with pytest.raises(NoSuchUserContextException, match=r"nope"):
Transport(connection).execute("bad.command", result=Result)


def test_an_error_code_this_schema_does_not_declare_still_raises():
connection = DrivingConnection(error="brand new code", message="from a newer browser")

with pytest.raises(WebDriverException, match=r"from a newer browser"):
Transport(connection).execute("bad.command", result=Result)


def test_a_wire_stacktrace_is_carried_onto_the_exception():
connection = DrivingConnection(error="unknown error", message="boom", stacktrace="a\nb")

with pytest.raises(WebDriverException) as exc_info:
Transport(connection).execute("bad.command", result=Result)
assert exc_info.value.stacktrace == ["a", "b"]


def test_execute_treats_the_presence_of_error_as_an_error_not_its_truthiness():
connection = DrivingConnection(error="")

Expand Down
Loading