You've already forked SeleniumHQ.selenium.py

The existing genrule does not handle directories properly. NOKEYCHECK=True GitOrigin-RevId: b803c686243787bc45a41732d9348b08afaff449
1045 lines
34 KiB
Python
1045 lines
34 KiB
Python
# 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)
|