| # 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. |
| |
| # flake8: noqa |
| |
| 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 |
| import typing |
| |
| 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 |
| 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: typing.Optional[str] |
| type: typing.Optional[str] |
| ref: typing.Optional[str] |
| enum: typing.List[str] |
| items: typing.Optional[CdpItems] |
| optional: bool |
| experimental: bool |
| deprecated: bool |
| |
| @property |
| def py_name(self): |
| ''' 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( |
| typing.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: typing.Optional[str] |
| type: str |
| items: typing.Optional[CdpItems] |
| enum: typing.List[str] |
| properties: typing.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( |
| typing.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: typing.List[CdpParameter] |
| returns: typing.List[CdpReturn] |
| domain: str |
| |
| @property |
| def py_name(self): |
| ''' 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), |
| [typing.cast(CdpParameter, CdpParameter.from_json(p)) for p in parameters], |
| [typing.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: typing.Optional[str] |
| deprecated: bool |
| experimental: bool |
| parameters: typing.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), |
| [typing.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: typing.Optional[str] |
| experimental: bool |
| dependencies: typing.List[str] |
| types: typing.List[CdpType] |
| commands: typing.List[CdpCommand] |
| events: typing.List[CdpEvent] |
| |
| @property |
| def module(self): |
| ''' 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 = typing.Union[CdpEvent, CdpCommand, CdpType] |
| item_iter: typing.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) |