diff --git a/py/BUILD.bazel b/py/BUILD.bazel index 158dc912d61ac..7427cc55f5edf 100644 --- a/py/BUILD.bazel +++ b/py/BUILD.bazel @@ -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 @@ -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( diff --git a/py/generate_bidi_protocol.py b/py/generate_bidi_protocol.py index 7579891da0d02..9fe0cd4d7e9ab 100644 --- a/py/generate_bidi_protocol.py +++ b/py/generate_bidi_protocol.py @@ -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: diff --git a/py/private/generate_bidi_protocol.bzl b/py/private/generate_bidi_protocol.bzl index cebe2a51ffad7..c3fc6e898f28a 100644 --- a/py/private/generate_bidi_protocol.bzl +++ b/py/private/generate_bidi_protocol.bzl @@ -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", diff --git a/py/selenium/webdriver/common/_bidi/transport.py b/py/selenium/webdriver/common/_bidi/transport.py index 24dc1b596837a..34c0bfa595e04 100644 --- a/py/selenium/webdriver/common/_bidi/transport.py +++ b/py/selenium/webdriver/common/_bidi/transport.py @@ -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: @@ -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 + stacktrace = reply.get("stacktrace") + return exception_for(code)(message, stacktrace=stacktrace.split("\n") if stacktrace else None) diff --git a/py/test/unit/selenium/webdriver/common/bidi_transport_tests.py b/py/test/unit/selenium/webdriver/common/bidi_transport_tests.py index a742d551b41d9..770997210a153 100644 --- a/py/test/unit/selenium/webdriver/common/bidi_transport_tests.py +++ b/py/test/unit/selenium/webdriver/common/bidi_transport_tests.py @@ -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 @@ -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): @@ -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} @@ -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) @@ -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="")