blob: 1e616d2d4adf9170c49ae3e9b8ab6f823d1aea2e [file]
# Licensed to the Software Freedom Conservancy (SFC) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The SFC licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Generate the Python WebDriver BiDi protocol layer from the shared schema.
Consumes the binding-neutral schema produced by the JavaScript projector
(``//javascript/selenium-webdriver:create-bidi-src_schema`` -> schema.json). The
schema is already normalized (inline enums hoisted, unions canonicalized with a
dispatch ``selector``, group composition flattened, wire names + nullability
preserved), so this generator is a straight projection into Python — no CDDL
interpretation. Ported from the Ruby generator (PR #17731).
Output is close to ``ruff format`` style; the build applies a format pass so the
checked-in files (and the verify test's comparison) are canonical.
Usage:
python generate_bidi_protocol.py <schema.json> <output_dir>
"""
from __future__ import annotations
import argparse
import json
import keyword
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
BIDI_DOC_URL = "https://www.selenium.dev/documentation/warnings/bidi-implementation/"
LINE_LIMIT = 120
DEFAULT_OUTPUT = "py/selenium/webdriver/common/_bidi"
_HEADER = """# Licensed to the Software Freedom Conservancy (SFC) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The SFC licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# This file is generated from the WebDriver BiDi specification.
# DO NOT EDIT. Regenerate with:
# bazel run //py:generate-bidi-protocol"""
_RESERVED_FIELDS = {"as_json", "from_json", "extensions"}
# The Enum mixin for a schema enum's declared value primitive. A primitive with no
# mixin (bool cannot be subclassed) gets a plain Enum: the runtime serializes through
# `.value`, so the member keeps its real type either way.
_ENUM_MIXINS = {"string": "str", "integer": "int", "number": "float"}
# --------------------------------------------------------------------------- #
# Naming helpers
# --------------------------------------------------------------------------- #
def camel_to_snake(name: str) -> str:
name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
name = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", name)
return name.lower()
def snake_to_class(name: str) -> str:
return "".join(part.capitalize() for part in name.split("_"))
def type_class_name(type_name: str) -> str:
"""Local class name for a schema type; synthetic ``X_Y`` becomes CamelCase ``XY``."""
local = type_name.split(".", 1)[-1]
parts = [p for p in local.split("_") if p]
return "".join(p[:1].upper() + p[1:] for p in parts) or local
def value_alias(type_name: str) -> str:
"""The TypeAlias name for a union's set of concrete variants."""
return f"{type_class_name(type_name)}Value"
def safe_name(name: str) -> str:
"""A snake_case identifier safe as a Python attribute / parameter.
Escapes hard keywords and names that would shadow the runtime API; soft
keywords (``type``, ``match``) are valid identifiers and left alone.
"""
return f"{name}_" if keyword.iskeyword(name) or name in _RESERVED_FIELDS else name
def enum_member(value: Any) -> str:
token = camel_to_snake(str(value))
token = re.sub(r"\A-(?=\d)", "neg", token)
token = re.sub(r"\A-", "neg_", token)
token = re.sub(r"[^a-z0-9]+", "_", token).strip("_").upper()
if not token or token[0].isdigit():
token = f"_{token}"
return token
def lit(value: Any) -> str:
"""A Python source literal, double-quoted (ruff-format style) for strings."""
if isinstance(value, bool):
return "True" if value else "False"
if isinstance(value, str):
return json.dumps(value)
if value is None:
return "None"
return repr(value)
def tuple_lit(items: tuple) -> str:
"""A tuple literal with double-quoted elements (single element keeps its comma)."""
inner = ", ".join(lit(i) for i in items)
return f"({inner},)" if len(items) == 1 else f"({inner})"
def frozenset_lit(values: list[str], indent: int) -> str:
"""`frozenset({...})` on one line, or ruff's wrapped form when it would overflow."""
one_line = f"frozenset({{{', '.join(values)}}})"
if indent + len(one_line) <= LINE_LIMIT:
return one_line
inner = ",\n".join(f"{' ' * (indent + 8)}{v}" for v in values)
return f"frozenset(\n{' ' * (indent + 4)}{{\n{inner},\n{' ' * (indent + 4)}}}\n{' ' * indent})"
def wrap_call(prefix: str, args: list[str], suffix: str, indent: int) -> str:
"""`prefix(args)suffix` on one line, or one arg per line when it would exceed the limit."""
one_line = f"{prefix}{', '.join(args)}{suffix}"
if not args or indent + len(one_line) <= LINE_LIMIT:
return one_line
pad = " " * (indent + 4)
body = ",\n".join(f"{pad}{a}" for a in args)
return f"{prefix}\n{body},\n{' ' * indent}{suffix.lstrip()}"
# --------------------------------------------------------------------------- #
# IR
# --------------------------------------------------------------------------- #
_NO_FIXED = object()
@dataclass
class ParamIR:
name: str
wire: str
required: bool
py_type: str
@dataclass
class CommandIR:
wire: str
method: str
params: list[ParamIR]
params_class: str | None
union_params: bool
result_class: str | None # runtime class passed to _execute (dispatch class)
result_type: str # return annotation
spec_href: str | None = None
@dataclass
class EventIR:
wire: str
name: str
payload_class: str | None
@dataclass
class EnumIR:
class_name: str
schema_name: str
members: list[tuple[str, Any]]
primitive: str | None = None
spec_href: str | None = None
@dataclass
class FieldIR:
name: str
wire: str
required: bool
nullable: bool
ref: str | None
is_list: bool
enum: str | None
primitive: str | None
scalar: str | list[str] | None
fixed: Any
py_type: str
@dataclass
class RecordIR:
class_name: str
schema_name: str
fields: list[FieldIR]
discriminator: FieldIR | None
extensible: bool
spec_href: str | None = None
@dataclass
class VariantIR:
mode: str
value: Any
ref: str
requires: tuple[str, ...] | None
@dataclass
class UnionIR:
class_name: str
schema_name: str
discriminator: str | None
discriminator_values: list[Any] | None
variants: list[VariantIR]
variant_types: list[str] # annotation names for the value alias
object_only: bool = False
spec_href: str | None = None
scalar_type: str | None = None # Literal[...] for an alias-union's bare-scalar arms
scalar_values: list[Any] | None = None # the literals a bare-scalar arm admits
@dataclass
class ModuleIR:
domain: str
class_name: str
filename: str
enums: list[EnumIR]
records: list[RecordIR]
unions: list[UnionIR]
commands: list[CommandIR]
events: list[EventIR]
imports: dict[str, set[str]] # module filename -> annotation names (cross-domain)
spec_href: str | None = None
# --------------------------------------------------------------------------- #
# Schema projection (ported from Ruby bidi_generate.rb Schema)
# --------------------------------------------------------------------------- #
# The complete set of primitives the projector emits; it rejects `unknown` upstream
# (an unhandled CDDL construct fails the schema check), so we never see one.
_PRIMITIVE = {"string": "str", "integer": "int", "number": "float", "boolean": "bool", "null": "None"}
_LEAF_PRIMITIVE = {"string": "str", "integer": "int", "number": "float", "boolean": "bool"}
class Schema:
def __init__(self, raw: dict) -> None:
self.types: dict = raw["types"]
self.commands: list = raw["commands"]
self.events: list = raw["events"]
self._domain_links: dict = raw.get("domains", {})
self._promote_command_params_records()
def _promote_command_params_records(self) -> None:
for cmd in self.commands:
ref = (cmd.get("params") or {}).get("ref")
if ref and ref in self.types and self._envelope_synthetic(self.types[ref]):
for tag in ("synthetic", "owner", "label"):
self.types[ref].pop(tag, None)
def domains(self) -> list[str]:
seen: list[str] = []
for entry in self.commands + self.events:
if entry["domain"] not in seen:
seen.append(entry["domain"])
return seen
def commands_for(self, domain: str) -> list:
return [c for c in self.commands if c["domain"] == domain]
def events_for(self, domain: str) -> list:
return [e for e in self.events if e["domain"] == domain]
def type_kind(self, ref: str | None) -> str | None:
return self.types.get(ref, {}).get("kind") if ref else None
def enums_for(self, domain: str) -> list[EnumIR]:
prefix = f"{domain}."
out = []
for name, type_ in self.types.items():
if type_.get("kind") != "enum" or not name.startswith(prefix):
continue
members = [(enum_member(v), v) for v in type_["values"]]
out.append(
EnumIR(
class_name=type_class_name(name),
schema_name=name,
members=members,
primitive=type_.get("primitive"),
spec_href=type_.get("specHref"),
)
)
return out
def types_for(self, domain: str) -> tuple[list[RecordIR], list[UnionIR]]:
prefix = f"{domain}."
records: list[RecordIR] = []
unions: list[UnionIR] = []
for name, type_ in self.types.items():
if not name.startswith(prefix):
continue
kind = type_["kind"]
if kind == "record":
if not type_["fields"] or self._suppressed_record(type_):
continue
records.append(self._record_ir(name, type_))
elif kind == "union":
unions.append(self._union_ir(name))
elif kind == "alias" and "union" in type_["type"]:
unions.append(self._union_ir(name))
return records, unions
def _suppressed_record(self, type_: dict) -> bool:
return self._message_envelope(type_) or self._envelope_synthetic(type_)
def _message_envelope(self, type_: dict) -> bool:
return any(f["wire"] == "method" and "const" in f["type"] for f in type_.get("fields", []))
def _envelope_synthetic(self, type_: dict) -> bool:
if not type_.get("synthetic"):
return False
owner = self.types.get(type_.get("owner"))
return bool(owner and owner["kind"] == "record" and self._message_envelope(owner))
def _record_ir(self, name: str, type_: dict) -> RecordIR:
disc_field = next((f for f in type_["fields"] if self._baked_discriminator(f)), None)
discriminator = self._field_ir(disc_field) if disc_field else None
fields = [self._field_ir(f) for f in type_["fields"] if not self._baked_discriminator(f)]
return RecordIR(
class_name=type_class_name(name),
schema_name=name,
fields=fields,
discriminator=discriminator,
# A type the spec marks extensible carries an untyped map for the fields the spec does
# not declare; every extensible type keeps them, received-only ones
# included. A non-extensible type gets no store.
extensible=bool(type_.get("extensible")),
spec_href=type_.get("specHref"),
)
def _baked_discriminator(self, field_: dict) -> bool:
return "const" in field_["type"] and not field_["type"].get("nullable")
def _field_ir(self, field_: dict) -> FieldIR:
resolved = self._resolve(field_["type"])
# Carry a const literal whether or not it is nullable. A non-nullable const is a baked
# discriminator (forced, init=False); a nullable const (e.g. ``bypass: true / null``) is a
# settable field held to its literal-or-null by the runtime.
const = field_["type"].get("const", _NO_FIXED)
py, refs = self.py_type(field_["type"])
self._pending_refs |= refs
return FieldIR(
name=safe_name(camel_to_snake(field_["name"])),
wire=field_["wire"],
required=bool(field_["required"]),
nullable=resolved["nullable"],
ref=resolved["ref"],
is_list=resolved["list"],
enum=self._enum_ref(field_["type"]),
primitive=self._leaf_primitive(field_["type"]),
scalar=self._scalar_py(resolved.get("scalar")),
fixed=const,
py_type=py,
)
# The projector's ``scalar`` primitive name(s) mapped to the runtime check names
# (``string`` -> ``str``), so a map key's bare scalar is validated with the same
# ``_PRIMITIVE_CHECKS`` table as a typed field. None when the field carries no scalar arm.
def _scalar_py(self, scalar: str | list | None) -> str | list[str] | None:
if scalar is None:
return None
if isinstance(scalar, list):
return [_LEAF_PRIMITIVE[s] for s in scalar if s in _LEAF_PRIMITIVE] or None
return _LEAF_PRIMITIVE.get(scalar)
def _union_ir(self, name: str) -> UnionIR:
type_ = self.types[name]
if type_["kind"] == "union":
ir = self._union_from_selector(name, type_["selector"])
else:
ir = self._union_from_alias(name)
# A non-object_only union has a bare-scalar arm; only const-literal arms (scalarValues)
# are modeled, so the runtime can validate an outbound scalar. Without them the runtime
# would accept any scalar, so fail here, at generation, not at a caller's runtime.
if not ir.object_only and not ir.scalar_values:
raise ValueError(f"non-object_only union {name} has no scalarValues to validate its bare-scalar arm")
for v in ir.variants:
py = self._py_ref(v.ref, self._pending_refs)
ir.variant_types.append(py)
if ir.scalar_type:
ir.variant_types.append(ir.scalar_type)
return ir
def _union_from_selector(self, name: str, selector: dict) -> UnionIR:
if selector.get("correlated"):
raise ValueError(f"correlated union {name} must not be emitted (resolved by request id)")
variants = self._discriminated_variants(selector) if selector.get("by") else self._ordered_variants(selector)
if not variants:
raise ValueError(f"union {name} selector yielded no dispatch variants")
values = self._discriminator_values(selector) if selector.get("by") else None
return UnionIR(
class_name=type_class_name(name),
schema_name=name,
discriminator=selector.get("by"),
discriminator_values=values,
variants=variants,
variant_types=[],
object_only=bool(self.types[name].get("objectOnly")),
spec_href=self.types[name].get("specHref"),
)
def _discriminated_variants(self, selector: dict) -> list[VariantIR]:
variants = [VariantIR("value", v["value"], v["ref"], None) for v in selector["variants"]]
if selector.get("default"):
variants.append(VariantIR("fallback", None, selector["default"], None))
return variants
def _ordered_variants(self, selector: dict) -> list[VariantIR]:
return [VariantIR("presence", None, arm["ref"], tuple(arm["requires"])) for arm in selector.get("ordered", [])]
# The union-wide allowed discriminator set: each variant's tag plus the default
# variant's own enum values for the discriminator field (spec-faithful outbound check).
def _discriminator_values(self, selector: dict) -> list[Any] | None:
tagged = [v["value"] for v in selector["variants"]]
if not tagged or not all(isinstance(v, str) for v in tagged):
return None # boolean/number tags need no membership check
default = selector.get("default")
by = selector["by"]
extra: list[Any] = []
default_type = self.types.get(default, {})
if default and default_type.get("kind") == "record":
df = next((f for f in default_type["fields"] if f["wire"] == by), None)
ref = df and df["type"].get("ref")
if ref and self.types.get(ref, {}).get("kind") == "enum":
extra = self.types[ref]["values"]
return list(dict.fromkeys(tagged + extra))
def _union_from_alias(self, name: str) -> UnionIR:
variants = []
discriminator = None
for arm in self.types[name]["type"]["union"]:
ref = arm.get("ref")
if not ref:
continue
const = next((f for f in self.types[ref]["fields"] if "const" in f["type"]), None)
if not const:
raise ValueError(f"alias-union {name} arm {ref} has no const discriminator")
discriminator = const["wire"]
variants.append(VariantIR("value", const["type"]["const"], ref, None))
values = [v.value for v in variants] if all(isinstance(v.value, str) for v in variants) else None
# An alias-union carries bare-scalar arms (input.Origin's "viewport"/"pointer"), so the
# projector leaves it unflagged and a non-object payload still passes through. Those arms
# have no ref, so they are absent from the record variants above; surface them in the value
# alias as a Literal[...] so a caller sees the scalar options, not only the record variant.
scalar_values = self.types[name]["type"].get("scalarValues") or []
scalar_type = f"Literal[{', '.join(repr(c) for c in scalar_values)}]" if scalar_values else None
object_only = bool(self.types[name].get("objectOnly"))
return UnionIR(
type_class_name(name),
name,
discriminator,
values,
variants,
[],
object_only=object_only,
spec_href=self.types[name].get("specHref"),
scalar_type=scalar_type,
scalar_values=scalar_values,
)
def params_for(self, params_ref: dict | None) -> list[ParamIR] | None:
if not params_ref:
return []
type_ = self.types.get(params_ref["ref"])
if not type_:
return None
if type_["kind"] == "record":
return [self._param_ir(f) for f in type_["fields"]]
if type_["kind"] == "union":
return self._union_params(type_, params_ref["ref"])
return None
def _param_ir(self, field_: dict, required: bool | None = None) -> ParamIR:
py, refs = self.py_type(field_["type"])
self._pending_refs |= refs
return ParamIR(
name=safe_name(camel_to_snake(field_["name"])),
wire=field_["wire"],
required=bool(field_["required"]) if required is None else required,
py_type=py,
)
def _union_params(self, type_: dict, ref: str) -> list[ParamIR] | None:
variants = [self.types.get(v) for v in type_["variants"]]
if not all(v and v["kind"] == "record" for v in variants):
return None
self._guard_union_dispatch_keys(type_["selector"], ref)
return self._merged_params([v["fields"] for v in variants])
def _merged_params(self, variant_fields: list[list[dict]]) -> list[ParamIR]:
all_fields = [f for fields in variant_fields for f in fields]
out: list[ParamIR] = []
for wire_name in dict.fromkeys(f["wire"] for f in all_fields):
field_ = next(f for f in all_fields if f["wire"] == wire_name)
required = all(any(f["wire"] == wire_name and f["required"] for f in fields) for fields in variant_fields)
out.append(self._param_ir(field_, required=required))
return out
def _guard_union_dispatch_keys(self, selector: dict, ref: str) -> None:
if selector.get("by"):
keys = [selector["by"]]
else:
keys = [k for arm in selector.get("ordered", []) for k in arm["requires"]]
camel = [k for k in keys if camel_to_snake(k) != k]
if camel:
raise ValueError(
f"union command param {ref} dispatches on non-snake wire key(s) {camel}; "
"Union.build matches kwargs to dispatch keys by name — add an explicit mapping first."
)
def structured_ref(self, name: str) -> str | None:
resolved = self._resolve_named(name, {})
return None if resolved["list"] else resolved["ref"]
# --- type resolution (serialization facts) ---
def _resolve(self, node: dict) -> dict:
nullable = bool(node.get("nullable"))
if "list" in node:
element = self._resolve(node["list"])
return {"ref": element["ref"], "list": True, "nullable": nullable, "scalar": element.get("scalar")}
if "ref" in node:
named = self._resolve_named(node["ref"], {})
return {"ref": named["ref"], "list": named["list"], "nullable": nullable, "scalar": named.get("scalar")}
if "union" in node:
return self._resolve_union(node, nullable)
return {"ref": None, "list": False, "nullable": nullable}
# An inline union of one union-typed arm plus scalars (a map entry's RemoteValue / text)
# collapses onto that union ref. Because the union is object_only, a bare-scalar sibling
# would raise there, so forward the projector's ``scalar`` signal — the runtime passes a
# non-object leaf (the map's string keys) through instead, checked against this primitive.
def _resolve_union(self, node: dict, nullable: bool) -> dict:
refs = [arm for arm in node["union"] if "ref" in arm]
if len(refs) == 1 and self._union_ref(refs[0]["ref"]):
named = self._resolve_named(refs[0]["ref"], {})
return {"ref": named["ref"], "list": named["list"], "nullable": nullable, "scalar": node.get("scalar")}
return {"ref": None, "list": False, "nullable": nullable}
def _union_ref(self, name: str) -> bool:
type_ = self.types.get(name)
if not type_:
return False
if type_["kind"] == "alias" and "ref" in type_["type"]:
return self._union_ref(type_["type"]["ref"])
return type_["kind"] == "union"
_OPAQUE = {"ref": None, "list": False}
def _resolve_named(self, name: str | None, seen: dict) -> dict:
if name is None or seen.get(name):
return dict(self._OPAQUE)
seen[name] = True
type_ = self.types.get(name)
if not type_:
return dict(self._OPAQUE)
kind = type_["kind"]
if kind == "record":
return dict(self._OPAQUE) if not type_["fields"] else {"ref": self._domain_ref(name), "list": False}
if kind == "union":
return {"ref": self._domain_ref(name), "list": False}
if kind == "enum":
return {"ref": None, "list": False}
if kind == "alias":
return self._resolve_named_alias(name, type_["type"], seen)
return dict(self._OPAQUE)
def _resolve_named_alias(self, name: str, inner: dict, seen: dict) -> dict:
if "union" in inner:
return {"ref": self._domain_ref(name), "list": False}
if "ref" in inner:
return self._resolve_named(inner["ref"], seen)
if "list" in inner:
element = self._resolve(inner["list"])
return {"ref": element["ref"], "list": True, "scalar": element.get("scalar")}
return {"ref": None, "list": False}
def _domain_ref(self, name: str) -> str | None:
return name if "." in name else None
def _enum_ref(self, node: dict) -> str | None:
ref = node.get("ref") or (node.get("list") or {}).get("ref")
if ref and self.types.get(ref, {}).get("kind") == "enum":
return ref
return None
# The runtime-checkable scalar type of a field (or of a list's elements),
# following alias chains (e.g. js-uint -> integer). None for a record ref,
# union, const, enum, or opaque/unknown type — those are validated elsewhere.
def _leaf_primitive(self, node: dict, seen: set[str] | None = None) -> str | None:
seen = seen or set()
if "list" in node:
return self._leaf_primitive(node["list"], seen)
if "primitive" in node:
return _LEAF_PRIMITIVE.get(node["primitive"])
if "ref" in node:
name = node["ref"]
type_ = self.types.get(name)
if name in seen or not type_ or type_["kind"] != "alias":
return None
seen.add(name)
return self._leaf_primitive(type_["type"], seen)
return None
# --- Python type annotations ---
def py_type(self, node: dict) -> tuple[str, set[str]]:
refs: set[str] = set()
base = self._py_base(node, refs)
nullable = node.get("nullable") and base != "None"
return (f"{base} | None" if nullable else base), refs
def _py_base(self, node: dict, refs: set[str]) -> str:
if "list" in node:
inner, _ = self._py_inner(node["list"], refs)
return f"list[{inner}]"
if "ref" in node:
return self._py_ref(node["ref"], refs)
if "union" in node:
return "Any"
if "const" in node:
value = node["const"]
if isinstance(value, bool):
return "bool"
if isinstance(value, str):
return "str"
return "float" if isinstance(value, float) else "int"
if "primitive" in node:
return _PRIMITIVE[node["primitive"]] # KeyError = a new schema primitive to handle (fail loud)
return "Any"
def _py_inner(self, node: dict, refs: set[str]) -> tuple[str, set[str]]:
base = self._py_base(node, refs)
return (f"{base} | None" if node.get("nullable") and base != "None" else base), refs
def _py_ref(self, name: str, refs: set[str], seen: set[str] | None = None) -> str:
seen = seen or set()
if name in seen:
return "Any"
seen.add(name)
type_ = self.types.get(name)
if not type_:
return "Any"
kind = type_["kind"]
if kind == "enum":
refs.add(name)
return type_class_name(name)
if kind == "record":
if not type_["fields"]:
return "Any"
refs.add(name)
return type_class_name(name)
if kind == "union":
refs.add(name)
return value_alias(name)
if kind == "alias":
inner = type_["type"]
if "union" in inner:
refs.add(name)
return value_alias(name)
if "ref" in inner:
return self._py_ref(inner["ref"], refs, seen)
if "list" in inner:
elem, _ = self._py_inner(inner["list"], refs)
return f"list[{elem}]"
return self._py_base(inner, refs)
return "Any"
_pending_refs: set[str] = set()
# --------------------------------------------------------------------------- #
# IR builders
# --------------------------------------------------------------------------- #
_PARAMS_CLASS_KINDS = {"record", "union"}
def build_command(schema: Schema, cmd: dict) -> CommandIR:
params = schema.params_for(cmd.get("params"))
if cmd.get("params") and params is None:
raise ValueError(f"command {cmd['method']} has params that cannot be expressed as a typed object")
params_ref = (cmd.get("params") or {}).get("ref")
kind = schema.type_kind(params_ref)
params_class = type_class_name(params_ref) if params and kind in _PARAMS_CLASS_KINDS else None
result_ref = (cmd.get("result") or {}).get("ref")
result_name = schema.structured_ref(result_ref) if result_ref else None
_guard_same_domain(cmd["domain"], params_ref if params_class else None, result_name, cmd["method"])
result_class = type_class_name(result_name) if result_name else None
if result_name and schema.type_kind(result_name) == "union":
result_type = value_alias(result_name)
elif result_class:
result_type = result_class
else:
result_type = "Any"
return CommandIR(
wire=cmd["method"],
method=safe_name(camel_to_snake(cmd["name"])),
params=params or [],
params_class=params_class,
union_params=(kind == "union"),
result_class=result_class,
result_type=result_type,
spec_href=cmd.get("specHref"),
)
def build_event(schema: Schema, event: dict) -> EventIR:
params = event.get("params")
payload_ref = params.get("ref") if params else None
payload = schema.structured_ref(payload_ref) if payload_ref else None
_guard_same_domain(event["domain"], None, payload, event["method"])
return EventIR(
wire=event["method"],
name=safe_name(camel_to_snake(event["name"])),
payload_class=type_class_name(payload) if payload else None,
)
def _guard_same_domain(domain: str, *type_names: str | None) -> None:
for name in type_names:
if name and "." in name and name.split(".", 1)[0] != domain:
raise ValueError(f"{domain}: cross-domain type reference {name!r} not yet supported")
def build_module(schema: Schema, domain: str) -> ModuleIR:
schema._pending_refs = set()
records, unions = schema.types_for(domain)
commands = [build_command(schema, c) for c in schema.commands_for(domain)]
events = [build_event(schema, e) for e in schema.events_for(domain)]
imports = _cross_domain_imports(schema, domain)
return ModuleIR(
domain=domain,
class_name=snake_to_class(camel_to_snake(domain)),
filename=camel_to_snake(domain),
enums=schema.enums_for(domain),
records=records,
unions=unions,
commands=commands,
events=events,
imports=imports,
spec_href=schema._domain_links.get(domain, {}).get("specHref"),
)
def _cross_domain_imports(schema: Schema, domain: str) -> dict[str, set[str]]:
imports: dict[str, set[str]] = {}
for ref in schema._pending_refs:
ref_domain = ref.split(".", 1)[0]
if ref_domain == domain:
continue
module = camel_to_snake(ref_domain)
is_union = schema.type_kind(ref) == "union" or _is_alias_union(schema, ref)
name = value_alias(ref) if is_union else type_class_name(ref)
imports.setdefault(module, set()).add(name)
return imports
def _is_alias_union(schema: Schema, ref: str) -> bool:
t = schema.types.get(ref, {})
return t.get("kind") == "alias" and "union" in t.get("type", {})
# --------------------------------------------------------------------------- #
# Emission
# --------------------------------------------------------------------------- #
def _annotation(f: FieldIR) -> str:
if f.fixed is not _NO_FIXED:
return f.py_type
if f.required:
return f.py_type
return f"{f.py_type} | UnsetType"
def _wire_args(f: FieldIR) -> list[str]:
args = [lit(f.wire)]
if f.required:
args.append("required=True")
if f.nullable:
args.append("nullable=True")
if f.ref:
args.append(f"ref={lit(f.ref)}")
if f.is_list:
args.append("is_list=True")
if f.enum:
args.append(f"enum={lit(f.enum)}")
if f.primitive:
args.append(f"primitive={lit(f.primitive)}")
if f.scalar:
value = f"[{', '.join(lit(s) for s in f.scalar)}]" if isinstance(f.scalar, list) else lit(f.scalar)
args.append(f"scalar={value}")
if f.fixed is not _NO_FIXED:
args.append(f"fixed={lit(f.fixed)}")
return args
def _emit_field(f: FieldIR, optional_default: bool) -> str:
meta_call = f"meta({', '.join(_wire_args(f))})"
field_args = []
# Bake only a non-nullable const (a discriminator): it is forced and not constructor-settable.
# A nullable const stays settable so a caller can pass its literal or ``None``.
if f.fixed is not _NO_FIXED and not f.nullable:
field_args += [f"default={lit(f.fixed)}", "init=False"]
elif optional_default:
field_args.append("default=UNSET")
field_args.append(f"metadata={meta_call}")
prefix = f" {f.name}: {_annotation(f)} = field("
return wrap_call(prefix, field_args, ")", 4)
def _spec_docstring(name: str, spec_href: str | None) -> list[str]:
"""Class docstring lines (with trailing blank) linking the element to its BiDi definition."""
if not spec_href:
return []
return [f' """{name}.', "", f" See {spec_href}", ' """', ""]
def _emit_enum(e: EnumIR) -> str:
mixin = _ENUM_MIXINS.get(e.primitive)
bases = f"{mixin}, Enum" if mixin else "Enum"
lines = [f"@register({lit(e.schema_name)})", f"class {e.class_name}({bases}):"]
lines += _spec_docstring(e.schema_name, e.spec_href)
for member, value in e.members:
lines.append(f" {member} = {lit(value)}")
return "\n".join(lines)
def _emit_record(r: RecordIR) -> str:
lines = [f"@register({lit(r.schema_name)})", "@dataclass(frozen=True)", f"class {r.class_name}(Record):"]
doc = _spec_docstring(r.schema_name, r.spec_href)
lines += doc
if r.extensible:
lines.append(" _EXTENSIBLE = True")
required = [f for f in r.fields if f.required]
optional = [f for f in r.fields if not f.required]
for f in required:
lines.append(_emit_field(f, optional_default=False))
if r.discriminator:
lines.append(_emit_field(r.discriminator, optional_default=False))
for f in optional:
lines.append(_emit_field(f, optional_default=True))
if r.extensible:
lines.append(' extensions: dict[str, Any] | UnsetType = field(default=UNSET, metadata=meta("extensions"))')
if not r.fields and not r.discriminator and not r.extensible and not doc:
lines.append(" pass")
return "\n".join(lines)
def _emit_union(u: UnionIR) -> str:
lines = [f"@register({lit(u.schema_name)})", f"class {u.class_name}(Union):"]
lines += _spec_docstring(u.schema_name, u.spec_href)
value_variants = [v for v in u.variants if v.mode == "value"]
fallback = next((v for v in u.variants if v.mode == "fallback"), None)
presence = [v for v in u.variants if v.mode == "presence"]
if u.discriminator is not None:
lines.append(f" _DISCRIMINATOR = {lit(u.discriminator)}")
if value_variants:
lines.append(" _VARIANTS = {")
for v in value_variants:
lines.append(f" {lit(v.value)}: {lit(v.ref)},")
lines.append(" }")
if presence:
entries = [f"({lit(v.ref)}, {tuple_lit(v.requires)})" for v in presence]
lines.append(wrap_call(" _PRESENCE = (", entries, ")", 4))
if fallback:
lines.append(f" _FALLBACK = {lit(fallback.ref)}")
if u.discriminator_values:
# Set literal inside frozenset (never frozenset(("x")) — a single value with no
# trailing comma is not a tuple, so it would iterate the string into characters).
values = [lit(v) for v in u.discriminator_values]
lines.append(f" _DISCRIMINATOR_VALUES = {frozenset_lit(values, 4)}")
if u.object_only:
lines.append(" _OBJECT_ONLY = True")
if u.scalar_values:
values = [lit(v) for v in u.scalar_values]
lines.append(f" _SCALAR_VALUES = {frozenset_lit(values, 4)}")
alias = _emit_type_alias(value_alias(u.schema_name), u.variant_types)
return "\n".join(lines) + "\n\n\n" + alias
def _emit_type_alias(name: str, variants: list[str]) -> str:
unique = list(dict.fromkeys(variants))
one_line = f'{name}: TypeAlias = "{" | ".join(unique)}"'
if len(one_line) <= LINE_LIMIT:
return one_line
# Wrap into parenthesized implicit string concatenation (E501-safe); adjacent
# string literals concatenate, and the " | " separators are kept intact.
tokens = [unique[0]] + [f" | {v}" for v in unique[1:]]
lines: list[str] = []
cur = ""
for tok in tokens:
if cur and len(f' "{cur}{tok}"') > LINE_LIMIT:
lines.append(f' "{cur}"')
cur = tok
else:
cur += tok
lines.append(f' "{cur}"')
return f"{name}: TypeAlias = (\n" + "\n".join(lines) + "\n)"
def _emit_command(c: CommandIR) -> list[str]:
required = [p for p in c.params if p.required]
optional = [p for p in c.params if not p.required]
sig = ["self"]
sig += [f"{p.name}: {p.py_type}" for p in required]
sig += [f"{p.name}: {p.py_type} | UnsetType = UNSET" for p in optional]
lines = [wrap_call(f" def {c.method}(", sig, f") -> {c.result_type}:", 4)]
if c.spec_href:
lines.append(f' """Execute {c.wire} (internal, unsupported).')
lines.append("")
lines.append(f" See {c.spec_href}")
lines.append(' """')
else:
lines.append(f' """Execute {c.wire} (internal, unsupported)."""')
if c.params_class:
kwargs = [f"{p.name}={p.name}" for p in c.params]
method = "build" if c.union_params else ""
prefix = f" params = {c.params_class}.{method}(" if method else f" params = {c.params_class}("
lines.append(wrap_call(prefix, kwargs, ")", 8))
params_arg = "params=params"
else:
params_arg = "params=None"
result_arg = f"result={c.result_class}" if c.result_class else "result=None"
lines.append(f" return self._execute({lit(c.wire)}, {params_arg}, {result_arg})")
return lines
def _emit_domain_class(mod: ModuleIR) -> str:
doc = [' """Internal, unsupported.', "", f" See {BIDI_DOC_URL}"]
if mod.spec_href:
doc.append(f" See {mod.spec_href}")
doc.append(' """')
lines = [f"class {mod.class_name}(Domain):", *doc]
if mod.events:
# A blank line separates the class docstring from the first body statement; the
# command methods below carry their own leading blank, so only EVENTS needs one here.
lines.append("")
lines.append(" EVENTS = {")
for e in mod.events:
lines.append(f" {lit(e.name)}: {lit(e.wire)},")
lines.append(" }")
lines.append(" EVENT_TYPES = {")
for e in mod.events:
payload = lit(f"{mod.domain}.{e.payload_class}") if e.payload_class else "None"
lines.append(f" {lit(e.wire)}: {payload},")
lines.append(" }")
body = []
for c in mod.commands:
body.append("")
body.extend(_emit_command(c))
return "\n".join(lines + body)
def _import_order(name: str) -> tuple[int, str]:
"""Ruff isort 'order-by-type': constants (UPPER), then classes, then functions."""
if name.isupper():
rank = 0
elif name[0].isupper():
rank = 1
else:
rank = 2
return (rank, name)
def _imports(mod: ModuleIR) -> list[str]:
has_records = bool(mod.records)
has_unions = bool(mod.unions)
uses_literal = any((u.scalar_type or "").startswith("Literal[") for u in mod.unions)
uses_unset = (
any(not f.required for r in mod.records for f in r.fields)
or any(r.extensible for r in mod.records)
or any(not p.required for c in mod.commands for p in c.params)
)
# "Any" appears in any emitted annotation
text_blob = " ".join(
[f.py_type for r in mod.records for f in r.fields]
+ [p.py_type for c in mod.commands for p in c.params]
+ [c.result_type for c in mod.commands]
+ ["dict[str, Any]" if any(r.extensible for r in mod.records) else ""]
)
uses_any = "Any" in text_blob.split() or "Any]" in text_blob or "[Any" in text_blob
typing_names = ["TYPE_CHECKING"] if mod.imports else []
if uses_any:
typing_names.append("Any")
if uses_literal:
typing_names.append("Literal")
if has_unions:
typing_names.append("TypeAlias")
runtime = []
if has_records:
runtime += ["Record", "meta"]
if has_unions:
runtime.append("Union")
if uses_unset:
runtime += ["UNSET", "UnsetType"]
if mod.enums or has_records or has_unions:
runtime.append("register")
lines = ["from __future__ import annotations", ""]
if has_records:
lines.append("from dataclasses import dataclass, field")
if mod.enums:
lines.append("from enum import Enum")
if typing_names:
lines.append(f"from typing import {', '.join(dict.fromkeys(typing_names))}")
lines.append("")
lines.append("from selenium.webdriver.common._bidi.domain import Domain")
if runtime:
names = ", ".join(sorted(set(runtime), key=_import_order))
lines.append(f"from selenium.webdriver.common._bidi.serialization import {names}")
if mod.imports:
lines += ["", "if TYPE_CHECKING:"]
for module in sorted(mod.imports):
names = ", ".join(sorted(mod.imports[module]))
lines.append(f" from selenium.webdriver.common._bidi.{module} import {names}")
return lines
def render_module(mod: ModuleIR) -> str:
blocks: list[str] = [_HEADER, "\n".join(_imports(mod))]
for e in mod.enums:
blocks.append(_emit_enum(e))
for r in mod.records:
blocks.append(_emit_record(r))
for u in mod.unions:
blocks.append(_emit_union(u))
blocks.append(_emit_domain_class(mod))
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 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()]
rendered = {f"{mod.filename}.py": render_module(mod) for mod in modules}
rendered["errors.py"] = render_errors(schema)
return rendered
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("schema", help="Path to the binding-neutral BiDi schema JSON")
parser.add_argument(
"output_dir",
nargs="?",
help="Output directory (default: <workspace>/py/selenium/webdriver/common/_bidi under `bazel run`)",
)
parser.add_argument(
"--modules",
help="Comma-separated module names the caller expects; errors if the schema's domains differ",
)
args = parser.parse_args()
out_dir = args.output_dir
if out_dir is None:
workspace = os.environ.get("BUILD_WORKSPACE_DIRECTORY")
if not workspace:
parser.error("output_dir is required unless run via `bazel run` (sets BUILD_WORKSPACE_DIRECTORY)")
out_dir = os.path.join(workspace, DEFAULT_OUTPUT)
rendered = render_all(args.schema)
if args.modules:
expected = set(args.modules.split(","))
actual = {name.removesuffix(".py") for name in rendered}
if expected != actual:
parser.error(
f"BiDi schema domains drifted from the declared module list: "
f"missing={sorted(expected - actual)} extra={sorted(actual - expected)}"
)
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
for name, contents in rendered.items():
(out / name).write_text(contents, encoding="utf-8", newline="\n")
print(f"bidi-generate: wrote {name}")
if __name__ == "__main__":
main()