| # The MIT License(MIT) |
| # |
| # Copyright(c) 2018 Hyperion Gray |
| # |
| # Permission is hereby granted, free of charge, to any person obtaining a copy |
| # of this software and associated documentation files(the "Software"), to deal |
| # in the Software without restriction, including without limitation the rights |
| # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell |
| # copies of the Software, and to permit persons to whom the Software is |
| # furnished to do so, subject to the following conditions: |
| # |
| # The above copyright notice and this permission notice shall be included in |
| # all copies or substantial portions of the Software. |
| # |
| # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN |
| # THE SOFTWARE. |
| |
| # This is a copy of https://github.com/HyperionGray/python-chrome-devtools-protocol/blob/master/generator/generate.py |
| # The license above is theirs and MUST be preserved. |
| |
| |
| import builtins |
| from dataclasses import dataclass |
| from enum import Enum |
| import itertools |
| import json |
| import logging |
| import operator |
| import os |
| from pathlib import Path |
| import re |
| from textwrap import dedent, indent as tw_indent |
| from typing import Optional, cast, List, Union |
| |
| from collections.abc import Iterator |
| |
| import inflection # type: ignore |
| |
| |
| log_level = getattr(logging, os.environ.get("LOG_LEVEL", "warning").upper()) |
| logging.basicConfig(level=log_level) |
| logger = logging.getLogger("generate") |
| |
| SHARED_HEADER = """# DO NOT EDIT THIS FILE! |
| # |
| # This file is generated from the CDP specification. If you need to make |
| # changes, edit the generator and regenerate all of the modules.""" |
| |
| INIT_HEADER = """{} |
| """.format(SHARED_HEADER) |
| |
| MODULE_HEADER = """{} |
| # |
| # CDP domain: {{}}{{}} |
| from __future__ import annotations |
| from .util import event_class, T_JSON_DICT |
| from dataclasses import dataclass |
| import enum |
| import typing |
| """.format(SHARED_HEADER) |
| |
| current_version = "" |
| |
| UTIL_PY = """ |
| import typing |
| |
| |
| T_JSON_DICT = typing.Dict[str, typing.Any] |
| _event_parsers = dict() |
| |
| |
| def event_class(method): |
| ''' A decorator that registers a class as an event class. ''' |
| def decorate(cls): |
| _event_parsers[method] = cls |
| cls.event_class = method |
| return cls |
| return decorate |
| |
| |
| def parse_json_event(json: T_JSON_DICT) -> typing.Any: |
| ''' Parse a JSON dictionary into a CDP event. ''' |
| return _event_parsers[json['method']].from_json(json['params']) |
| """ |
| |
| |
| def indent(s, n): |
| """A shortcut for ``textwrap.indent`` that always uses spaces.""" |
| return tw_indent(s, n * " ") |
| |
| |
| BACKTICK_RE = re.compile(r"`([^`]+)`(\w+)?") |
| |
| |
| def escape_backticks(docstr): |
| """ |
| Escape backticks in a docstring by doubling them up. |
| This is a little tricky because RST requires a non-letter character after |
| the closing backticks, but some CDPs docs have things like "`AxNodeId`s". |
| If we double the backticks in that string, then it won't be valid RST. The |
| fix is to insert an apostrophe if an "s" trails the backticks. |
| """ |
| |
| def replace_one(match): |
| if match.group(2) == "s": |
| return f"``{match.group(1)}``'s" |
| if match.group(2): |
| # This case (some trailer other than "s") doesn't currently exist |
| # in the CDP definitions, but it's here just to be safe. |
| return f"``{match.group(1)}`` {match.group(2)}" |
| return f"``{match.group(1)}``" |
| |
| # Sometimes pipes are used where backticks should have been used. |
| docstr = docstr.replace("|", "`") |
| return BACKTICK_RE.sub(replace_one, docstr) |
| |
| |
| def inline_doc(description): |
| """Generate an inline doc, e.g. ``#: This type is a ...``""" |
| if not description: |
| return "" |
| |
| description = escape_backticks(description) |
| lines = [f"#: {l}" for l in description.split("\n")] |
| return "\n".join(lines) |
| |
| |
| def docstring(description): |
| """Generate a docstring from a description.""" |
| if not description: |
| return "" |
| |
| description = escape_backticks(description) |
| return dedent("'''\n{}\n'''").format(description) |
| |
| |
| def is_builtin(name): |
| """Return True if ``name`` would shadow a builtin.""" |
| try: |
| getattr(builtins, name) |
| return True |
| except AttributeError: |
| return False |
| |
| |
| def snake_case(name): |
| """Convert a camel case name to snake case. If the name would shadow a |
| Python builtin, then append an underscore.""" |
| name = inflection.underscore(name) |
| if is_builtin(name): |
| name += "_" |
| return name |
| |
| |
| def ref_to_python(ref): |
| """ |
| Convert a CDP ``$ref`` to the name of a Python type. |
| For a dotted ref, the part before the dot is snake cased. |
| """ |
| if "." in ref: |
| domain, subtype = ref.split(".") |
| ref = f"{snake_case(domain)}.{subtype}" |
| return f"{ref}" |
| |
| |
| class CdpPrimitiveType(Enum): |
| """All of the CDP types that map directly to a Python type.""" |
| |
| boolean = "bool" |
| integer = "int" |
| number = "float" |
| object = "dict" |
| string = "str" |
| |
| @classmethod |
| def get_annotation(cls, cdp_type): |
| """Return a type annotation for the CDP type.""" |
| if cdp_type == "any": |
| return "typing.Any" |
| return cls[cdp_type].value |
| |
| @classmethod |
| def get_constructor(cls, cdp_type, val): |
| """Return the code to construct a value for a given CDP type.""" |
| if cdp_type == "any": |
| return val |
| cons = cls[cdp_type].value |
| return f"{cons}({val})" |
| |
| |
| @dataclass |
| class CdpItems: |
| """Represents the type of a repeated item.""" |
| |
| type: str |
| ref: str |
| |
| @classmethod |
| def from_json(cls, type): |
| """Generate code to instantiate an item from a JSON object.""" |
| return cls(type.get("type"), type.get("$ref")) |
| |
| |
| @dataclass |
| class CdpProperty: |
| """A property belonging to a non-primitive CDP type.""" |
| |
| name: str |
| description: str | None |
| type: str | None |
| ref: str | None |
| enum: list[str] |
| items: CdpItems | None |
| optional: bool |
| experimental: bool |
| deprecated: bool |
| |
| @property |
| def py_name(self) -> str: |
| """Get this property's Python name.""" |
| return snake_case(self.name) |
| |
| @property |
| def py_annotation(self): |
| """This property's Python type annotation.""" |
| if self.items: |
| if self.items.ref: |
| py_ref = ref_to_python(self.items.ref) |
| ann = f"typing.List[{py_ref}]" |
| else: |
| ann = "typing.List[{}]".format(CdpPrimitiveType.get_annotation(self.items.type)) |
| else: |
| if self.ref: |
| py_ref = ref_to_python(self.ref) |
| ann = py_ref |
| else: |
| ann = CdpPrimitiveType.get_annotation(cast(str, self.type)) |
| if self.optional: |
| ann = f"typing.Optional[{ann}]" |
| return ann |
| |
| @classmethod |
| def from_json(cls, property): |
| """Instantiate a CDP property from a JSON object.""" |
| return cls( |
| property["name"], |
| property.get("description"), |
| property.get("type"), |
| property.get("$ref"), |
| property.get("enum"), |
| CdpItems.from_json(property["items"]) if "items" in property else None, |
| property.get("optional", False), |
| property.get("experimental", False), |
| property.get("deprecated", False), |
| ) |
| |
| def generate_decl(self): |
| """Generate the code that declares this property.""" |
| code = inline_doc(self.description) |
| if code: |
| code += "\n" |
| code += f"{self.py_name}: {self.py_annotation}" |
| if self.optional: |
| code += " = None" |
| return code |
| |
| def generate_to_json(self, dict_, use_self=True): |
| """Generate the code that exports this property to the specified JSON |
| dict.""" |
| self_ref = "self." if use_self else "" |
| assign = f"{dict_}['{self.name}'] = " |
| if self.items: |
| if self.items.ref: |
| assign += f"[i.to_json() for i in {self_ref}{self.py_name}]" |
| else: |
| assign += f"[i for i in {self_ref}{self.py_name}]" |
| else: |
| if self.ref: |
| assign += f"{self_ref}{self.py_name}.to_json()" |
| else: |
| assign += f"{self_ref}{self.py_name}" |
| if self.optional: |
| code = dedent(f"""\ |
| if {self_ref}{self.py_name} is not None: |
| {assign}""") |
| else: |
| code = assign |
| return code |
| |
| def generate_from_json(self, dict_): |
| """Generate the code that creates an instance from a JSON dict named |
| ``dict_``.""" |
| if self.items: |
| if self.items.ref: |
| py_ref = ref_to_python(self.items.ref) |
| expr = f"[{py_ref}.from_json(i) for i in {dict_}['{self.name}']]" |
| expr |
| else: |
| cons = CdpPrimitiveType.get_constructor(self.items.type, "i") |
| expr = f"[{cons} for i in {dict_}['{self.name}']]" |
| else: |
| if self.ref: |
| py_ref = ref_to_python(self.ref) |
| expr = f"{py_ref}.from_json({dict_}['{self.name}'])" |
| else: |
| expr = CdpPrimitiveType.get_constructor(self.type, f"{dict_}['{self.name}']") |
| if self.optional: |
| expr = f"{expr} if '{self.name}' in {dict_} else None" |
| return expr |
| |
| |
| @dataclass |
| class CdpType: |
| """A top-level CDP type.""" |
| |
| id: str |
| description: str | None |
| type: str |
| items: CdpItems | None |
| enum: list[str] |
| properties: list[CdpProperty] |
| |
| @classmethod |
| def from_json(cls, type_): |
| """Instantiate a CDP type from a JSON object.""" |
| return cls( |
| type_["id"], |
| type_.get("description"), |
| type_["type"], |
| CdpItems.from_json(type_["items"]) if "items" in type_ else None, |
| type_.get("enum"), |
| [CdpProperty.from_json(p) for p in type_.get("properties", [])], |
| ) |
| |
| def generate_code(self): |
| """Generate Python code for this type.""" |
| logger.debug("Generating type %s: %s", self.id, self.type) |
| if self.enum: |
| return self.generate_enum_code() |
| if self.properties: |
| return self.generate_class_code() |
| return self.generate_primitive_code() |
| |
| def generate_primitive_code(self): |
| """Generate code for a primitive type.""" |
| if self.items: |
| if self.items.ref: |
| nested_type = ref_to_python(self.items.ref) |
| else: |
| nested_type = CdpPrimitiveType.get_annotation(self.items.type) |
| py_type = f"typing.List[{nested_type}]" |
| superclass = "list" |
| else: |
| # A primitive type cannot have a ref, so there is no branch here. |
| py_type = CdpPrimitiveType.get_annotation(self.type) |
| superclass = py_type |
| |
| code = f"class {self.id}({superclass}):\n" |
| doc = docstring(self.description) |
| if doc: |
| code += indent(doc, 4) + "\n" |
| |
| def_to_json = dedent(f"""\ |
| def to_json(self) -> {py_type}: |
| return self""") |
| code += indent(def_to_json, 4) |
| |
| def_from_json = dedent(f"""\ |
| @classmethod |
| def from_json(cls, json: {py_type}) -> {self.id}: |
| return cls(json)""") |
| code += "\n\n" + indent(def_from_json, 4) |
| |
| def_repr = dedent(f"""\ |
| def __repr__(self): |
| return '{self.id}({{}})'.format(super().__repr__())""") |
| code += "\n\n" + indent(def_repr, 4) |
| |
| return code |
| |
| def generate_enum_code(self): |
| """ |
| Generate an "enum" type. |
| Enums are handled by making a python class that contains only class |
| members. Each class member is upper snaked case, e.g. |
| ``MyTypeClass.MY_ENUM_VALUE`` and is assigned a string value from the |
| CDP metadata. |
| """ |
| def_to_json = dedent("""\ |
| def to_json(self): |
| return self.value""") |
| |
| def_from_json = dedent("""\ |
| @classmethod |
| def from_json(cls, json): |
| return cls(json)""") |
| |
| code = f"class {self.id}(enum.Enum):\n" |
| doc = docstring(self.description) |
| if doc: |
| code += indent(doc, 4) + "\n" |
| for enum_member in self.enum: |
| snake_name = snake_case(enum_member).upper() |
| enum_code = f'{snake_name} = "{enum_member}"\n' |
| code += indent(enum_code, 4) |
| code += "\n" + indent(def_to_json, 4) |
| code += "\n\n" + indent(def_from_json, 4) |
| |
| return code |
| |
| def generate_class_code(self): |
| """ |
| Generate a class type. |
| Top-level types that are defined as a CDP ``object`` are turned into Python |
| dataclasses. |
| """ |
| # children = set() |
| code = dedent(f"""\ |
| @dataclass |
| class {self.id}:\n""") |
| doc = docstring(self.description) |
| if doc: |
| code += indent(doc, 4) + "\n" |
| |
| # Emit property declarations. These are sorted so that optional |
| # properties come after required properties, which is required to make |
| # the dataclass constructor work. |
| props = list(self.properties) |
| props.sort(key=operator.attrgetter("optional")) |
| code += "\n\n".join(indent(p.generate_decl(), 4) for p in props) |
| code += "\n\n" |
| |
| # Emit to_json() method. The properties are sorted in the same order as |
| # above for readability. |
| def_to_json = dedent("""\ |
| def to_json(self): |
| json = dict() |
| """) |
| assigns = (p.generate_to_json(dict_="json") for p in props) |
| def_to_json += indent("\n".join(assigns), 4) |
| def_to_json += "\n" |
| def_to_json += indent("return json", 4) |
| code += indent(def_to_json, 4) + "\n\n" |
| |
| # Emit from_json() method. The properties are sorted in the same order |
| # as above for readability. |
| def_from_json = dedent("""\ |
| @classmethod |
| def from_json(cls, json): |
| return cls( |
| """) |
| from_jsons = [] |
| for p in props: |
| from_json = p.generate_from_json(dict_="json") |
| from_jsons.append(f"{p.py_name}={from_json},") |
| def_from_json += indent("\n".join(from_jsons), 8) |
| def_from_json += "\n" |
| def_from_json += indent(")", 4) |
| code += indent(def_from_json, 4) |
| |
| return code |
| |
| def get_refs(self): |
| """Return all refs for this type.""" |
| refs = set() |
| if self.enum: |
| # Enum types don't have refs. |
| pass |
| elif self.properties: |
| # Enumerate refs for a class type. |
| for prop in self.properties: |
| if prop.items and prop.items.ref: |
| refs.add(prop.items.ref) |
| elif prop.ref: |
| refs.add(prop.ref) |
| else: |
| # A primitive type can't have a direct ref, but it can have an items |
| # which contains a ref. |
| if self.items and self.items.ref: |
| refs.add(self.items.ref) |
| return refs |
| |
| |
| class CdpParameter(CdpProperty): |
| """A parameter to a CDP command.""" |
| |
| def generate_code(self): |
| """Generate the code for a parameter in a function call.""" |
| if self.items: |
| if self.items.ref: |
| nested_type = ref_to_python(self.items.ref) |
| py_type = f"typing.List[{nested_type}]" |
| else: |
| nested_type = CdpPrimitiveType.get_annotation(self.items.type) |
| py_type = f"typing.List[{nested_type}]" |
| else: |
| if self.ref: |
| py_type = f"{ref_to_python(self.ref)}" |
| else: |
| py_type = CdpPrimitiveType.get_annotation(cast(str, self.type)) |
| if self.optional: |
| py_type = f"typing.Optional[{py_type}]" |
| code = f"{self.py_name}: {py_type}" |
| if self.optional: |
| code += " = None" |
| return code |
| |
| def generate_decl(self): |
| """Generate the declaration for this parameter.""" |
| if self.description: |
| code = inline_doc(self.description) |
| code += "\n" |
| else: |
| code = "" |
| code += f"{self.py_name}: {self.py_annotation}" |
| return code |
| |
| def generate_doc(self): |
| """Generate the docstring for this parameter.""" |
| doc = f":param {self.py_name}:" |
| |
| if self.experimental: |
| doc += " **(EXPERIMENTAL)**" |
| |
| if self.optional: |
| doc += " *(Optional)*" |
| |
| if self.description: |
| desc = self.description.replace("`", "``").replace("\n", " ") |
| doc += f" {desc}" |
| return doc |
| |
| def generate_from_json(self, dict_): |
| """ |
| Generate the code to instantiate this parameter from a JSON dict. |
| """ |
| code = super().generate_from_json(dict_) |
| return f"{self.py_name}={code}" |
| |
| |
| class CdpReturn(CdpProperty): |
| """A return value from a CDP command.""" |
| |
| @property |
| def py_annotation(self): |
| """Return the Python type annotation for this return.""" |
| if self.items: |
| if self.items.ref: |
| py_ref = ref_to_python(self.items.ref) |
| ann = f"typing.List[{py_ref}]" |
| else: |
| py_type = CdpPrimitiveType.get_annotation(self.items.type) |
| ann = f"typing.List[{py_type}]" |
| else: |
| if self.ref: |
| py_ref = ref_to_python(self.ref) |
| ann = f"{py_ref}" |
| else: |
| ann = CdpPrimitiveType.get_annotation(self.type) |
| if self.optional: |
| ann = f"typing.Optional[{ann}]" |
| return ann |
| |
| def generate_doc(self): |
| """Generate the docstring for this return.""" |
| if self.description: |
| doc = self.description.replace("\n", " ") |
| if self.optional: |
| doc = f"*(Optional)* {doc}" |
| else: |
| doc = "" |
| return doc |
| |
| def generate_return(self, dict_): |
| """Generate code for returning this value.""" |
| return super().generate_from_json(dict_) |
| |
| |
| @dataclass |
| class CdpCommand: |
| """A CDP command.""" |
| |
| name: str |
| description: str |
| experimental: bool |
| deprecated: bool |
| parameters: list[CdpParameter] |
| returns: list[CdpReturn] |
| domain: str |
| |
| @property |
| def py_name(self) -> str: |
| """Get a Python name for this command.""" |
| return snake_case(self.name) |
| |
| @classmethod |
| def from_json(cls, command, domain) -> "CdpCommand": |
| """Instantiate a CDP command from a JSON object.""" |
| parameters = command.get("parameters", []) |
| returns = command.get("returns", []) |
| |
| return cls( |
| command["name"], |
| command.get("description"), |
| command.get("experimental", False), |
| command.get("deprecated", False), |
| [cast(CdpParameter, CdpParameter.from_json(p)) for p in parameters], |
| [cast(CdpReturn, CdpReturn.from_json(r)) for r in returns], |
| domain, |
| ) |
| |
| def generate_code(self): |
| """Generate code for a CDP command.""" |
| global current_version |
| # Generate the function header |
| if len(self.returns) == 0: |
| ret_type = "None" |
| elif len(self.returns) == 1: |
| ret_type = self.returns[0].py_annotation |
| else: |
| nested_types = ", ".join(r.py_annotation for r in self.returns) |
| ret_type = f"typing.Tuple[{nested_types}]" |
| ret_type = f"typing.Generator[T_JSON_DICT,T_JSON_DICT,{ret_type}]" |
| |
| code = "" |
| |
| code += f"def {self.py_name}(" |
| ret = f") -> {ret_type}:\n" |
| if self.parameters: |
| params = [p.generate_code() for p in self.parameters] |
| optional = False |
| clean_params = [] |
| for para in params: |
| if "= None" in para: |
| optional = True |
| if optional and "= None" not in para: |
| para += " = None" |
| clean_params.append(para) |
| code += "\n" |
| code += indent(",\n".join(clean_params), 8) |
| code += "\n" |
| code += indent(ret, 4) |
| else: |
| code += ret |
| |
| # Generate the docstring |
| doc = "" |
| if self.description: |
| doc = self.description |
| if self.experimental: |
| doc += "\n\n**EXPERIMENTAL**" |
| if self.parameters and doc: |
| doc += "\n\n" |
| elif not self.parameters and self.returns: |
| doc += "\n" |
| doc += "\n".join(p.generate_doc() for p in self.parameters) |
| if len(self.returns) == 1: |
| doc += "\n" |
| ret_doc = self.returns[0].generate_doc() |
| doc += f":returns: {ret_doc}" |
| elif len(self.returns) > 1: |
| doc += "\n" |
| doc += ":returns: A tuple with the following items:\n\n" |
| ret_docs = "\n".join(f"{i}. **{r.name}** - {r.generate_doc()}" for i, r in enumerate(self.returns)) |
| doc += indent(ret_docs, 4) |
| if doc: |
| code += indent(docstring(doc), 4) |
| |
| # Generate the function body |
| if self.parameters: |
| code += "\n" |
| code += indent("params: T_JSON_DICT = dict()", 4) |
| code += "\n" |
| assigns = (p.generate_to_json(dict_="params", use_self=False) for p in self.parameters) |
| code += indent("\n".join(assigns), 4) |
| code += "\n" |
| code += indent("cmd_dict: T_JSON_DICT = {\n", 4) |
| code += indent(f"'method': '{self.domain}.{self.name}',\n", 8) |
| if self.parameters: |
| code += indent("'params': params,\n", 8) |
| code += indent("}\n", 4) |
| code += indent("json = yield cmd_dict", 4) |
| if len(self.returns) == 0: |
| pass |
| elif len(self.returns) == 1: |
| ret = self.returns[0].generate_return(dict_="json") |
| code += indent(f"\nreturn {ret}", 4) |
| else: |
| ret = "\nreturn (\n" |
| expr = ",\n".join(r.generate_return(dict_="json") for r in self.returns) |
| ret += indent(expr, 4) |
| ret += "\n)" |
| code += indent(ret, 4) |
| return code |
| |
| def get_refs(self): |
| """Get all refs for this command.""" |
| refs = set() |
| for type_ in itertools.chain(self.parameters, self.returns): |
| if type_.items and type_.items.ref: |
| refs.add(type_.items.ref) |
| elif type_.ref: |
| refs.add(type_.ref) |
| return refs |
| |
| |
| @dataclass |
| class CdpEvent: |
| """A CDP event object.""" |
| |
| name: str |
| description: str | None |
| deprecated: bool |
| experimental: bool |
| parameters: list[CdpParameter] |
| domain: str |
| |
| @property |
| def py_name(self): |
| """Return the Python class name for this event.""" |
| return inflection.camelize(self.name, uppercase_first_letter=True) |
| |
| @classmethod |
| def from_json(cls, json: dict, domain: str): |
| """Create a new CDP event instance from a JSON dict.""" |
| return cls( |
| json["name"], |
| json.get("description"), |
| json.get("deprecated", False), |
| json.get("experimental", False), |
| [cast(CdpParameter, CdpParameter.from_json(p)) for p in json.get("parameters", [])], |
| domain, |
| ) |
| |
| def generate_code(self): |
| """Generate code for a CDP event.""" |
| global current_version |
| code = dedent(f"""\ |
| @event_class('{self.domain}.{self.name}') |
| @dataclass |
| class {self.py_name}:""") |
| |
| code += "\n" |
| desc = "" |
| if self.description or self.experimental: |
| if self.experimental: |
| desc += "**EXPERIMENTAL**\n\n" |
| |
| if self.description: |
| desc += self.description |
| |
| code += indent(docstring(desc), 4) |
| code += "\n" |
| code += indent("\n".join(p.generate_decl() for p in self.parameters), 4) |
| code += "\n\n" |
| def_from_json = dedent(f"""\ |
| @classmethod |
| def from_json(cls, json: T_JSON_DICT) -> {self.py_name}: |
| return cls( |
| """) |
| code += indent(def_from_json, 4) |
| from_json = ",\n".join(p.generate_from_json(dict_="json") for p in self.parameters) |
| code += indent(from_json, 12) |
| code += "\n" |
| code += indent(")", 8) |
| return code |
| |
| def get_refs(self): |
| """Get all refs for this event.""" |
| refs = set() |
| for param in self.parameters: |
| if param.items and param.items.ref: |
| refs.add(param.items.ref) |
| elif param.ref: |
| refs.add(param.ref) |
| return refs |
| |
| |
| @dataclass |
| class CdpDomain: |
| """A CDP domain contains metadata, types, commands, and events.""" |
| |
| domain: str |
| description: str | None |
| experimental: bool |
| dependencies: list[str] |
| types: list[CdpType] |
| commands: list[CdpCommand] |
| events: list[CdpEvent] |
| |
| @property |
| def module(self) -> str: |
| """The name of the Python module for this CDP domain.""" |
| return snake_case(self.domain) |
| |
| @classmethod |
| def from_json(cls, domain: dict): |
| """Instantiate a CDP domain from a JSON object.""" |
| types = domain.get("types", []) |
| commands = domain.get("commands", []) |
| events = domain.get("events", []) |
| domain_name = domain["domain"] |
| |
| return cls( |
| domain_name, |
| domain.get("description"), |
| domain.get("experimental", False), |
| domain.get("dependencies", []), |
| [CdpType.from_json(type) for type in types], |
| [CdpCommand.from_json(command, domain_name) for command in commands], |
| [CdpEvent.from_json(event, domain_name) for event in events], |
| ) |
| |
| def generate_code(self): |
| """Generate the Python module code for a given CDP domain.""" |
| exp = " (experimental)" if self.experimental else "" |
| code = MODULE_HEADER.format(self.domain, exp) |
| import_code = self.generate_imports() |
| if import_code: |
| code += import_code |
| code += "\n\n" |
| code += "\n" |
| item_iter_t = Union[CdpEvent, CdpCommand, CdpType] |
| item_iter: Iterator[item_iter_t] = itertools.chain( |
| iter(self.types), |
| iter(self.commands), |
| iter(self.events), |
| ) |
| code += "\n\n\n".join(item.generate_code() for item in item_iter) |
| code += "\n" |
| return code |
| |
| def generate_imports(self): |
| """ |
| Determine which modules this module depends on and emit the code to |
| import those modules. |
| Notice that CDP defines a ``dependencies`` field for each domain, but |
| these dependencies are a subset of the modules that we actually need to |
| import to make our Python code work correctly and type safe. So we |
| ignore the CDP's declared dependencies and compute them ourselves. |
| """ |
| refs = set() |
| for type_ in self.types: |
| refs |= type_.get_refs() |
| for command in self.commands: |
| refs |= command.get_refs() |
| for event in self.events: |
| refs |= event.get_refs() |
| dependencies = set() |
| for ref in refs: |
| try: |
| domain, _ = ref.split(".") |
| except ValueError: |
| continue |
| if domain != self.domain: |
| dependencies.add(snake_case(domain)) |
| code = "\n".join(f"from . import {d}" for d in sorted(dependencies)) |
| |
| return code |
| |
| def generate_sphinx(self): |
| """ |
| Generate a Sphinx document for this domain. |
| """ |
| docs = self.domain + "\n" |
| docs += "=" * len(self.domain) + "\n\n" |
| if self.description: |
| docs += f"{self.description}\n\n" |
| if self.experimental: |
| docs += "*This CDP domain is experimental.*\n\n" |
| docs += f".. module:: cdp.{self.module}\n\n" |
| docs += "* Types_\n* Commands_\n* Events_\n\n" |
| |
| docs += "Types\n-----\n\n" |
| if self.types: |
| docs += dedent("""\ |
| Generally, you do not need to instantiate CDP types |
| yourself. Instead, the API creates objects for you as return |
| values from commands, and then you can use those objects as |
| arguments to other commands. |
| """) |
| else: |
| docs += "*There are no types in this module.*\n" |
| for type in self.types: |
| docs += f"\n.. autoclass:: {type.id}\n" |
| docs += " :members:\n" |
| docs += " :undoc-members:\n" |
| docs += " :exclude-members: from_json, to_json\n" |
| |
| docs += "\nCommands\n--------\n\n" |
| if self.commands: |
| docs += dedent("""\ |
| Each command is a generator function. The return |
| type ``Generator[x, y, z]`` indicates that the generator |
| *yields* arguments of type ``x``, it must be resumed with |
| an argument of type ``y``, and it returns type ``z``. In |
| this library, types ``x`` and ``y`` are the same for all |
| commands, and ``z`` is the return type you should pay attention |
| to. For more information, see |
| :ref:`Getting Started: Commands <getting-started-commands>`. |
| """) |
| else: |
| docs += "*There are no types in this module.*\n" |
| for command in sorted(self.commands, key=operator.attrgetter("py_name")): |
| docs += f"\n.. autofunction:: {command.py_name}\n" |
| |
| docs += "\nEvents\n------\n\n" |
| if self.events: |
| docs += dedent("""\ |
| Generally, you do not need to instantiate CDP events |
| yourself. Instead, the API creates events for you and then |
| you use the event\'s attributes. |
| """) |
| else: |
| docs += "*There are no events in this module.*\n" |
| for event in self.events: |
| docs += f"\n.. autoclass:: {event.py_name}\n" |
| docs += " :members:\n" |
| docs += " :undoc-members:\n" |
| docs += " :exclude-members: from_json, to_json\n" |
| |
| return docs |
| |
| |
| def parse(json_path, output_path): |
| """ |
| Parse JSON protocol description and return domain objects. |
| :param Path json_path: path to a JSON CDP schema |
| :param Path output_path: a directory path to create the modules in |
| :returns: a list of CDP domain objects |
| """ |
| global current_version |
| with open(json_path, encoding="utf-8") as json_file: |
| schema = json.load(json_file) |
| version = schema["version"] |
| assert (version["major"], version["minor"]) == ("1", "3") |
| current_version = f"{version['major']}.{version['minor']}" |
| domains = [] |
| for domain in schema["domains"]: |
| domains.append(CdpDomain.from_json(domain)) |
| return domains |
| |
| |
| def generate_init(init_path, domains): |
| """ |
| Generate an ``__init__.py`` that exports the specified modules. |
| :param Path init_path: a file path to create the init file in |
| :param list[tuple] modules: a list of modules each represented as tuples |
| of (name, list_of_exported_symbols) |
| """ |
| with open(init_path, "w", encoding="utf-8") as init_file: |
| init_file.write(INIT_HEADER) |
| for domain in domains: |
| init_file.write(f"from . import {domain.module}\n") |
| init_file.write("from . import util\n\n") |
| |
| |
| def generate_docs(docs_path, domains): |
| """ |
| Generate Sphinx documents for each domain. |
| """ |
| logger.info("Generating Sphinx documents") |
| |
| # Remove generated documents |
| for subpath in docs_path.iterdir(): |
| subpath.unlink() |
| |
| # Generate document for each domain |
| for domain in domains: |
| doc = docs_path / f"{domain.module}.rst" |
| with doc.open("w") as f: |
| f.write(domain.generate_sphinx()) |
| |
| |
| def main(browser_protocol_path, js_protocol_path, output_path): |
| """Main entry point.""" |
| output_path = Path(output_path).resolve() |
| json_paths = [ |
| browser_protocol_path, |
| js_protocol_path, |
| ] |
| |
| # Generate util.py |
| util_path = output_path / "util.py" |
| with util_path.open("w") as util_file: |
| util_file.write(UTIL_PY) |
| |
| # Remove generated code |
| for subpath in output_path.iterdir(): |
| if subpath.is_file() and subpath.name not in ("py.typed", "util.py"): |
| subpath.unlink() |
| |
| # Parse domains |
| domains = [] |
| for json_path in json_paths: |
| logger.info("Parsing JSON file %s", json_path) |
| domains.extend(parse(json_path, output_path)) |
| domains.sort(key=operator.attrgetter("domain")) |
| |
| # Patch up CDP errors. It's easier to patch that here than it is to modify |
| # the generator code. |
| # 1. DOM includes an erroneous $ref that refers to itself. |
| # 2. Page includes an event with an extraneous backtick in the description. |
| for domain in domains: |
| if domain.domain == "DOM": |
| for cmd in domain.commands: |
| if cmd.name == "resolveNode": |
| # Patch 1 |
| cmd.parameters[1].ref = "BackendNodeId" |
| elif domain.domain == "Page": |
| for event in domain.events: |
| if event.name == "screencastVisibilityChanged": |
| # Patch 2 |
| event.description = event.description.replace("`", "") |
| |
| for domain in domains: |
| logger.info("Generating module: %s → %s.py", domain.domain, domain.module) |
| module_path = output_path / f"{domain.module}.py" |
| with module_path.open("w") as module_file: |
| module_file.write(domain.generate_code()) |
| |
| init_path = output_path / "__init__.py" |
| generate_init(init_path, domains) |
| |
| # Not generating the docs as we don't want people to directly |
| # Use the CDP APIs |
| # docs_path = here.parent / 'docs' / 'api' |
| # generate_docs(docs_path, domains) |
| |
| py_typed_path = output_path / "py.typed" |
| py_typed_path.touch() |
| |
| |
| if __name__ == "__main__": |
| import sys |
| |
| assert sys.version_info >= (3, 7), "To generate the CDP code requires python 3.7 or later" |
| args = sys.argv[1:] |
| main(*args) |