Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
seleniumhq
GitHub Repository: seleniumhq/selenium
Path: blob/trunk/py/generate.py
2864 views
1
# The MIT License(MIT)
2
#
3
# Copyright(c) 2018 Hyperion Gray
4
#
5
# Permission is hereby granted, free of charge, to any person obtaining a copy
6
# of this software and associated documentation files(the "Software"), to deal
7
# in the Software without restriction, including without limitation the rights
8
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
9
# copies of the Software, and to permit persons to whom the Software is
10
# furnished to do so, subject to the following conditions:
11
#
12
# The above copyright notice and this permission notice shall be included in
13
# all copies or substantial portions of the Software.
14
#
15
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
# THE SOFTWARE.
22
23
# This is a copy of https://github.com/HyperionGray/python-chrome-devtools-protocol/blob/master/generator/generate.py
24
# The license above is theirs and MUST be preserved.
25
26
# flake8: noqa
27
28
import builtins
29
from dataclasses import dataclass
30
from enum import Enum
31
import itertools
32
import json
33
import logging
34
import operator
35
import os
36
from pathlib import Path
37
import re
38
from textwrap import dedent, indent as tw_indent
39
from typing import Optional , cast, List, Union, Iterator
40
41
import inflection # type: ignore
42
43
44
log_level = getattr(logging, os.environ.get('LOG_LEVEL', 'warning').upper())
45
logging.basicConfig(level=log_level)
46
logger = logging.getLogger('generate')
47
48
SHARED_HEADER = '''# DO NOT EDIT THIS FILE!
49
#
50
# This file is generated from the CDP specification. If you need to make
51
# changes, edit the generator and regenerate all of the modules.'''
52
53
INIT_HEADER = '''{}
54
'''.format(SHARED_HEADER)
55
56
MODULE_HEADER = '''{}
57
#
58
# CDP domain: {{}}{{}}
59
from __future__ import annotations
60
from .util import event_class, T_JSON_DICT
61
from dataclasses import dataclass
62
import enum
63
import typing
64
'''.format(SHARED_HEADER)
65
66
current_version = ''
67
68
UTIL_PY = """
69
import typing
70
71
72
T_JSON_DICT = typing.Dict[str, typing.Any]
73
_event_parsers = dict()
74
75
76
def event_class(method):
77
''' A decorator that registers a class as an event class. '''
78
def decorate(cls):
79
_event_parsers[method] = cls
80
cls.event_class = method
81
return cls
82
return decorate
83
84
85
def parse_json_event(json: T_JSON_DICT) -> typing.Any:
86
''' Parse a JSON dictionary into a CDP event. '''
87
return _event_parsers[json['method']].from_json(json['params'])
88
"""
89
90
91
def indent(s, n):
92
''' A shortcut for ``textwrap.indent`` that always uses spaces. '''
93
return tw_indent(s, n * ' ')
94
95
96
BACKTICK_RE = re.compile(r'`([^`]+)`(\w+)?')
97
98
99
def escape_backticks(docstr):
100
'''
101
Escape backticks in a docstring by doubling them up.
102
This is a little tricky because RST requires a non-letter character after
103
the closing backticks, but some CDPs docs have things like "`AxNodeId`s".
104
If we double the backticks in that string, then it won't be valid RST. The
105
fix is to insert an apostrophe if an "s" trails the backticks.
106
'''
107
def replace_one(match):
108
if match.group(2) == 's':
109
return f"``{match.group(1)}``'s"
110
if match.group(2):
111
# This case (some trailer other than "s") doesn't currently exist
112
# in the CDP definitions, but it's here just to be safe.
113
return f'``{match.group(1)}`` {match.group(2)}'
114
return f'``{match.group(1)}``'
115
116
# Sometimes pipes are used where backticks should have been used.
117
docstr = docstr.replace('|', '`')
118
return BACKTICK_RE.sub(replace_one, docstr)
119
120
121
def inline_doc(description):
122
''' Generate an inline doc, e.g. ``#: This type is a ...`` '''
123
if not description:
124
return ''
125
126
description = escape_backticks(description)
127
lines = [f'#: {l}' for l in description.split('\n')]
128
return '\n'.join(lines)
129
130
131
def docstring(description):
132
''' Generate a docstring from a description. '''
133
if not description:
134
return ''
135
136
description = escape_backticks(description)
137
return dedent("'''\n{}\n'''").format(description)
138
139
140
def is_builtin(name):
141
''' Return True if ``name`` would shadow a builtin. '''
142
try:
143
getattr(builtins, name)
144
return True
145
except AttributeError:
146
return False
147
148
149
def snake_case(name):
150
''' Convert a camel case name to snake case. If the name would shadow a
151
Python builtin, then append an underscore. '''
152
name = inflection.underscore(name)
153
if is_builtin(name):
154
name += '_'
155
return name
156
157
158
def ref_to_python(ref):
159
'''
160
Convert a CDP ``$ref`` to the name of a Python type.
161
For a dotted ref, the part before the dot is snake cased.
162
'''
163
if '.' in ref:
164
domain, subtype = ref.split('.')
165
ref = f'{snake_case(domain)}.{subtype}'
166
return f"{ref}"
167
168
169
class CdpPrimitiveType(Enum):
170
''' All of the CDP types that map directly to a Python type. '''
171
boolean = 'bool'
172
integer = 'int'
173
number = 'float'
174
object = 'dict'
175
string = 'str'
176
177
@classmethod
178
def get_annotation(cls, cdp_type):
179
''' Return a type annotation for the CDP type. '''
180
if cdp_type == 'any':
181
return 'typing.Any'
182
return cls[cdp_type].value
183
184
@classmethod
185
def get_constructor(cls, cdp_type, val):
186
''' Return the code to construct a value for a given CDP type. '''
187
if cdp_type == 'any':
188
return val
189
cons = cls[cdp_type].value
190
return f'{cons}({val})'
191
192
193
@dataclass
194
class CdpItems:
195
''' Represents the type of a repeated item. '''
196
type: str
197
ref: str
198
199
@classmethod
200
def from_json(cls, type):
201
''' Generate code to instantiate an item from a JSON object. '''
202
return cls(type.get('type'), type.get('$ref'))
203
204
205
@dataclass
206
class CdpProperty:
207
''' A property belonging to a non-primitive CDP type. '''
208
name: str
209
description: Optional[str]
210
type: Optional[str]
211
ref: Optional[str]
212
enum: List[str]
213
items: Optional[CdpItems]
214
optional: bool
215
experimental: bool
216
deprecated: bool
217
218
@property
219
def py_name(self):
220
''' Get this property's Python name. '''
221
return snake_case(self.name)
222
223
@property
224
def py_annotation(self):
225
''' This property's Python type annotation. '''
226
if self.items:
227
if self.items.ref:
228
py_ref = ref_to_python(self.items.ref)
229
ann = f"typing.List[{py_ref}]"
230
else:
231
ann = 'typing.List[{}]'.format(
232
CdpPrimitiveType.get_annotation(self.items.type))
233
else:
234
if self.ref:
235
py_ref = ref_to_python(self.ref)
236
ann = py_ref
237
else:
238
ann = CdpPrimitiveType.get_annotation(
239
cast(str, self.type))
240
if self.optional:
241
ann = f'typing.Optional[{ann}]'
242
return ann
243
244
@classmethod
245
def from_json(cls, property):
246
''' Instantiate a CDP property from a JSON object. '''
247
return cls(
248
property['name'],
249
property.get('description'),
250
property.get('type'),
251
property.get('$ref'),
252
property.get('enum'),
253
CdpItems.from_json(property['items']) if 'items' in property else None,
254
property.get('optional', False),
255
property.get('experimental', False),
256
property.get('deprecated', False),
257
)
258
259
def generate_decl(self):
260
''' Generate the code that declares this property. '''
261
code = inline_doc(self.description)
262
if code:
263
code += '\n'
264
code += f'{self.py_name}: {self.py_annotation}'
265
if self.optional:
266
code += ' = None'
267
return code
268
269
def generate_to_json(self, dict_, use_self=True):
270
''' Generate the code that exports this property to the specified JSON
271
dict. '''
272
self_ref = 'self.' if use_self else ''
273
assign = f"{dict_}['{self.name}'] = "
274
if self.items:
275
if self.items.ref:
276
assign += f"[i.to_json() for i in {self_ref}{self.py_name}]"
277
else:
278
assign += f"[i for i in {self_ref}{self.py_name}]"
279
else:
280
if self.ref:
281
assign += f"{self_ref}{self.py_name}.to_json()"
282
else:
283
assign += f"{self_ref}{self.py_name}"
284
if self.optional:
285
code = dedent(f'''\
286
if {self_ref}{self.py_name} is not None:
287
{assign}''')
288
else:
289
code = assign
290
return code
291
292
def generate_from_json(self, dict_):
293
''' Generate the code that creates an instance from a JSON dict named
294
``dict_``. '''
295
if self.items:
296
if self.items.ref:
297
py_ref = ref_to_python(self.items.ref)
298
expr = f"[{py_ref}.from_json(i) for i in {dict_}['{self.name}']]"
299
expr
300
else:
301
cons = CdpPrimitiveType.get_constructor(self.items.type, 'i')
302
expr = f"[{cons} for i in {dict_}['{self.name}']]"
303
else:
304
if self.ref:
305
py_ref = ref_to_python(self.ref)
306
expr = f"{py_ref}.from_json({dict_}['{self.name}'])"
307
else:
308
expr = CdpPrimitiveType.get_constructor(self.type,
309
f"{dict_}['{self.name}']")
310
if self.optional:
311
expr = f"{expr} if '{self.name}' in {dict_} else None"
312
return expr
313
314
315
@dataclass
316
class CdpType:
317
''' A top-level CDP type. '''
318
id: str
319
description: Optional[str]
320
type: str
321
items: Optional[CdpItems]
322
enum: List[str]
323
properties: List[CdpProperty]
324
325
@classmethod
326
def from_json(cls, type_):
327
''' Instantiate a CDP type from a JSON object. '''
328
return cls(
329
type_['id'],
330
type_.get('description'),
331
type_['type'],
332
CdpItems.from_json(type_['items']) if 'items' in type_ else None,
333
type_.get('enum'),
334
[CdpProperty.from_json(p) for p in type_.get('properties', [])],
335
)
336
337
def generate_code(self):
338
''' Generate Python code for this type. '''
339
logger.debug('Generating type %s: %s', self.id, self.type)
340
if self.enum:
341
return self.generate_enum_code()
342
if self.properties:
343
return self.generate_class_code()
344
return self.generate_primitive_code()
345
346
def generate_primitive_code(self):
347
''' Generate code for a primitive type. '''
348
if self.items:
349
if self.items.ref:
350
nested_type = ref_to_python(self.items.ref)
351
else:
352
nested_type = CdpPrimitiveType.get_annotation(self.items.type)
353
py_type = f'typing.List[{nested_type}]'
354
superclass = 'list'
355
else:
356
# A primitive type cannot have a ref, so there is no branch here.
357
py_type = CdpPrimitiveType.get_annotation(self.type)
358
superclass = py_type
359
360
code = f'class {self.id}({superclass}):\n'
361
doc = docstring(self.description)
362
if doc:
363
code += indent(doc, 4) + '\n'
364
365
def_to_json = dedent(f'''\
366
def to_json(self) -> {py_type}:
367
return self''')
368
code += indent(def_to_json, 4)
369
370
def_from_json = dedent(f'''\
371
@classmethod
372
def from_json(cls, json: {py_type}) -> {self.id}:
373
return cls(json)''')
374
code += '\n\n' + indent(def_from_json, 4)
375
376
def_repr = dedent(f'''\
377
def __repr__(self):
378
return '{self.id}({{}})'.format(super().__repr__())''')
379
code += '\n\n' + indent(def_repr, 4)
380
381
return code
382
383
def generate_enum_code(self):
384
'''
385
Generate an "enum" type.
386
Enums are handled by making a python class that contains only class
387
members. Each class member is upper snaked case, e.g.
388
``MyTypeClass.MY_ENUM_VALUE`` and is assigned a string value from the
389
CDP metadata.
390
'''
391
def_to_json = dedent('''\
392
def to_json(self):
393
return self.value''')
394
395
def_from_json = dedent('''\
396
@classmethod
397
def from_json(cls, json):
398
return cls(json)''')
399
400
code = f'class {self.id}(enum.Enum):\n'
401
doc = docstring(self.description)
402
if doc:
403
code += indent(doc, 4) + '\n'
404
for enum_member in self.enum:
405
snake_name = snake_case(enum_member).upper()
406
enum_code = f'{snake_name} = "{enum_member}"\n'
407
code += indent(enum_code, 4)
408
code += '\n' + indent(def_to_json, 4)
409
code += '\n\n' + indent(def_from_json, 4)
410
411
return code
412
413
def generate_class_code(self):
414
'''
415
Generate a class type.
416
Top-level types that are defined as a CDP ``object`` are turned into Python
417
dataclasses.
418
'''
419
# children = set()
420
code = dedent(f'''\
421
@dataclass
422
class {self.id}:\n''')
423
doc = docstring(self.description)
424
if doc:
425
code += indent(doc, 4) + '\n'
426
427
# Emit property declarations. These are sorted so that optional
428
# properties come after required properties, which is required to make
429
# the dataclass constructor work.
430
props = list(self.properties)
431
props.sort(key=operator.attrgetter('optional'))
432
code += '\n\n'.join(indent(p.generate_decl(), 4) for p in props)
433
code += '\n\n'
434
435
# Emit to_json() method. The properties are sorted in the same order as
436
# above for readability.
437
def_to_json = dedent('''\
438
def to_json(self):
439
json = dict()
440
''')
441
assigns = (p.generate_to_json(dict_='json') for p in props)
442
def_to_json += indent('\n'.join(assigns), 4)
443
def_to_json += '\n'
444
def_to_json += indent('return json', 4)
445
code += indent(def_to_json, 4) + '\n\n'
446
447
# Emit from_json() method. The properties are sorted in the same order
448
# as above for readability.
449
def_from_json = dedent('''\
450
@classmethod
451
def from_json(cls, json):
452
return cls(
453
''')
454
from_jsons = []
455
for p in props:
456
from_json = p.generate_from_json(dict_='json')
457
from_jsons.append(f'{p.py_name}={from_json},')
458
def_from_json += indent('\n'.join(from_jsons), 8)
459
def_from_json += '\n'
460
def_from_json += indent(')', 4)
461
code += indent(def_from_json, 4)
462
463
return code
464
465
def get_refs(self):
466
''' Return all refs for this type. '''
467
refs = set()
468
if self.enum:
469
# Enum types don't have refs.
470
pass
471
elif self.properties:
472
# Enumerate refs for a class type.
473
for prop in self.properties:
474
if prop.items and prop.items.ref:
475
refs.add(prop.items.ref)
476
elif prop.ref:
477
refs.add(prop.ref)
478
else:
479
# A primitive type can't have a direct ref, but it can have an items
480
# which contains a ref.
481
if self.items and self.items.ref:
482
refs.add(self.items.ref)
483
return refs
484
485
486
class CdpParameter(CdpProperty):
487
''' A parameter to a CDP command. '''
488
489
def generate_code(self):
490
''' Generate the code for a parameter in a function call. '''
491
if self.items:
492
if self.items.ref:
493
nested_type = ref_to_python(self.items.ref)
494
py_type = f"typing.List[{nested_type}]"
495
else:
496
nested_type = CdpPrimitiveType.get_annotation(self.items.type)
497
py_type = f'typing.List[{nested_type}]'
498
else:
499
if self.ref:
500
py_type = f"{ref_to_python(self.ref)}"
501
else:
502
py_type = CdpPrimitiveType.get_annotation(
503
cast(str, self.type))
504
if self.optional:
505
py_type = f'typing.Optional[{py_type}]'
506
code = f"{self.py_name}: {py_type}"
507
if self.optional:
508
code += ' = None'
509
return code
510
511
def generate_decl(self):
512
''' Generate the declaration for this parameter. '''
513
if self.description:
514
code = inline_doc(self.description)
515
code += '\n'
516
else:
517
code = ''
518
code += f'{self.py_name}: {self.py_annotation}'
519
return code
520
521
def generate_doc(self):
522
''' Generate the docstring for this parameter. '''
523
doc = f':param {self.py_name}:'
524
525
if self.experimental:
526
doc += ' **(EXPERIMENTAL)**'
527
528
if self.optional:
529
doc += ' *(Optional)*'
530
531
if self.description:
532
desc = self.description.replace('`', '``').replace('\n', ' ')
533
doc += f' {desc}'
534
return doc
535
536
def generate_from_json(self, dict_):
537
'''
538
Generate the code to instantiate this parameter from a JSON dict.
539
'''
540
code = super().generate_from_json(dict_)
541
return f'{self.py_name}={code}'
542
543
544
class CdpReturn(CdpProperty):
545
''' A return value from a CDP command. '''
546
@property
547
def py_annotation(self):
548
''' Return the Python type annotation for this return. '''
549
if self.items:
550
if self.items.ref:
551
py_ref = ref_to_python(self.items.ref)
552
ann = f"typing.List[{py_ref}]"
553
else:
554
py_type = CdpPrimitiveType.get_annotation(self.items.type)
555
ann = f'typing.List[{py_type}]'
556
else:
557
if self.ref:
558
py_ref = ref_to_python(self.ref)
559
ann = f"{py_ref}"
560
else:
561
ann = CdpPrimitiveType.get_annotation(self.type)
562
if self.optional:
563
ann = f'typing.Optional[{ann}]'
564
return ann
565
566
def generate_doc(self):
567
''' Generate the docstring for this return. '''
568
if self.description:
569
doc = self.description.replace('\n', ' ')
570
if self.optional:
571
doc = f'*(Optional)* {doc}'
572
else:
573
doc = ''
574
return doc
575
576
def generate_return(self, dict_):
577
''' Generate code for returning this value. '''
578
return super().generate_from_json(dict_)
579
580
581
@dataclass
582
class CdpCommand:
583
''' A CDP command. '''
584
name: str
585
description: str
586
experimental: bool
587
deprecated: bool
588
parameters: List[CdpParameter]
589
returns: List[CdpReturn]
590
domain: str
591
592
@property
593
def py_name(self):
594
''' Get a Python name for this command. '''
595
return snake_case(self.name)
596
597
@classmethod
598
def from_json(cls, command, domain) -> 'CdpCommand':
599
''' Instantiate a CDP command from a JSON object. '''
600
parameters = command.get('parameters', [])
601
returns = command.get('returns', [])
602
603
return cls(
604
command['name'],
605
command.get('description'),
606
command.get('experimental', False),
607
command.get('deprecated', False),
608
[cast(CdpParameter, CdpParameter.from_json(p)) for p in parameters],
609
[cast(CdpReturn, CdpReturn.from_json(r)) for r in returns],
610
domain,
611
)
612
613
def generate_code(self):
614
''' Generate code for a CDP command. '''
615
global current_version
616
# Generate the function header
617
if len(self.returns) == 0:
618
ret_type = 'None'
619
elif len(self.returns) == 1:
620
ret_type = self.returns[0].py_annotation
621
else:
622
nested_types = ', '.join(r.py_annotation for r in self.returns)
623
ret_type = f'typing.Tuple[{nested_types}]'
624
ret_type = f"typing.Generator[T_JSON_DICT,T_JSON_DICT,{ret_type}]"
625
626
code = ''
627
628
code += f'def {self.py_name}('
629
ret = f') -> {ret_type}:\n'
630
if self.parameters:
631
params = [p.generate_code() for p in self.parameters]
632
optional = False
633
clean_params = []
634
for para in params:
635
if "= None" in para:
636
optional = True
637
if optional and "= None" not in para:
638
para += ' = None'
639
clean_params.append(para)
640
code += '\n'
641
code += indent(
642
',\n'.join(clean_params), 8)
643
code += '\n'
644
code += indent(ret, 4)
645
else:
646
code += ret
647
648
# Generate the docstring
649
doc = ''
650
if self.description:
651
doc = self.description
652
if self.experimental:
653
doc += '\n\n**EXPERIMENTAL**'
654
if self.parameters and doc:
655
doc += '\n\n'
656
elif not self.parameters and self.returns:
657
doc += '\n'
658
doc += '\n'.join(p.generate_doc() for p in self.parameters)
659
if len(self.returns) == 1:
660
doc += '\n'
661
ret_doc = self.returns[0].generate_doc()
662
doc += f':returns: {ret_doc}'
663
elif len(self.returns) > 1:
664
doc += '\n'
665
doc += ':returns: A tuple with the following items:\n\n'
666
ret_docs = '\n'.join(f'{i}. **{r.name}** - {r.generate_doc()}' for i, r
667
in enumerate(self.returns))
668
doc += indent(ret_docs, 4)
669
if doc:
670
code += indent(docstring(doc), 4)
671
672
# Generate the function body
673
if self.parameters:
674
code += '\n'
675
code += indent('params: T_JSON_DICT = dict()', 4)
676
code += '\n'
677
assigns = (p.generate_to_json(dict_='params', use_self=False)
678
for p in self.parameters)
679
code += indent('\n'.join(assigns), 4)
680
code += '\n'
681
code += indent('cmd_dict: T_JSON_DICT = {\n', 4)
682
code += indent(f"'method': '{self.domain}.{self.name}',\n", 8)
683
if self.parameters:
684
code += indent("'params': params,\n", 8)
685
code += indent('}\n', 4)
686
code += indent('json = yield cmd_dict', 4)
687
if len(self.returns) == 0:
688
pass
689
elif len(self.returns) == 1:
690
ret = self.returns[0].generate_return(dict_='json')
691
code += indent(f'\nreturn {ret}', 4)
692
else:
693
ret = '\nreturn (\n'
694
expr = ',\n'.join(r.generate_return(dict_='json') for r in self.returns)
695
ret += indent(expr, 4)
696
ret += '\n)'
697
code += indent(ret, 4)
698
return code
699
700
def get_refs(self):
701
''' Get all refs for this command. '''
702
refs = set()
703
for type_ in itertools.chain(self.parameters, self.returns):
704
if type_.items and type_.items.ref:
705
refs.add(type_.items.ref)
706
elif type_.ref:
707
refs.add(type_.ref)
708
return refs
709
710
711
@dataclass
712
class CdpEvent:
713
''' A CDP event object. '''
714
name: str
715
description: Optional[str]
716
deprecated: bool
717
experimental: bool
718
parameters: List[CdpParameter]
719
domain: str
720
721
@property
722
def py_name(self):
723
''' Return the Python class name for this event. '''
724
return inflection.camelize(self.name, uppercase_first_letter=True)
725
726
@classmethod
727
def from_json(cls, json: dict, domain: str):
728
''' Create a new CDP event instance from a JSON dict. '''
729
return cls(
730
json['name'],
731
json.get('description'),
732
json.get('deprecated', False),
733
json.get('experimental', False),
734
[cast(CdpParameter, CdpParameter.from_json(p))
735
for p in json.get('parameters', [])],
736
domain
737
)
738
739
def generate_code(self):
740
''' Generate code for a CDP event. '''
741
global current_version
742
code = dedent(f'''\
743
@event_class('{self.domain}.{self.name}')
744
@dataclass
745
class {self.py_name}:''')
746
747
code += '\n'
748
desc = ''
749
if self.description or self.experimental:
750
if self.experimental:
751
desc += '**EXPERIMENTAL**\n\n'
752
753
if self.description:
754
desc += self.description
755
756
code += indent(docstring(desc), 4)
757
code += '\n'
758
code += indent(
759
'\n'.join(p.generate_decl() for p in self.parameters), 4)
760
code += '\n\n'
761
def_from_json = dedent(f'''\
762
@classmethod
763
def from_json(cls, json: T_JSON_DICT) -> {self.py_name}:
764
return cls(
765
''')
766
code += indent(def_from_json, 4)
767
from_json = ',\n'.join(p.generate_from_json(dict_='json')
768
for p in self.parameters)
769
code += indent(from_json, 12)
770
code += '\n'
771
code += indent(')', 8)
772
return code
773
774
def get_refs(self):
775
''' Get all refs for this event. '''
776
refs = set()
777
for param in self.parameters:
778
if param.items and param.items.ref:
779
refs.add(param.items.ref)
780
elif param.ref:
781
refs.add(param.ref)
782
return refs
783
784
785
@dataclass
786
class CdpDomain:
787
''' A CDP domain contains metadata, types, commands, and events. '''
788
domain: str
789
description: Optional[str]
790
experimental: bool
791
dependencies: List[str]
792
types: List[CdpType]
793
commands: List[CdpCommand]
794
events: List[CdpEvent]
795
796
@property
797
def module(self):
798
''' The name of the Python module for this CDP domain. '''
799
return snake_case(self.domain)
800
801
@classmethod
802
def from_json(cls, domain: dict):
803
''' Instantiate a CDP domain from a JSON object. '''
804
types = domain.get('types', [])
805
commands = domain.get('commands', [])
806
events = domain.get('events', [])
807
domain_name = domain['domain']
808
809
return cls(
810
domain_name,
811
domain.get('description'),
812
domain.get('experimental', False),
813
domain.get('dependencies', []),
814
[CdpType.from_json(type) for type in types],
815
[CdpCommand.from_json(command, domain_name)
816
for command in commands],
817
[CdpEvent.from_json(event, domain_name) for event in events]
818
)
819
820
def generate_code(self):
821
''' Generate the Python module code for a given CDP domain. '''
822
exp = ' (experimental)' if self.experimental else ''
823
code = MODULE_HEADER.format(self.domain, exp)
824
import_code = self.generate_imports()
825
if import_code:
826
code += import_code
827
code += '\n\n'
828
code += '\n'
829
item_iter_t = Union[CdpEvent, CdpCommand, CdpType]
830
item_iter: Iterator[item_iter_t] = itertools.chain(
831
iter(self.types),
832
iter(self.commands),
833
iter(self.events),
834
)
835
code += '\n\n\n'.join(item.generate_code() for item in item_iter)
836
code += '\n'
837
return code
838
839
def generate_imports(self):
840
'''
841
Determine which modules this module depends on and emit the code to
842
import those modules.
843
Notice that CDP defines a ``dependencies`` field for each domain, but
844
these dependencies are a subset of the modules that we actually need to
845
import to make our Python code work correctly and type safe. So we
846
ignore the CDP's declared dependencies and compute them ourselves.
847
'''
848
refs = set()
849
for type_ in self.types:
850
refs |= type_.get_refs()
851
for command in self.commands:
852
refs |= command.get_refs()
853
for event in self.events:
854
refs |= event.get_refs()
855
dependencies = set()
856
for ref in refs:
857
try:
858
domain, _ = ref.split('.')
859
except ValueError:
860
continue
861
if domain != self.domain:
862
dependencies.add(snake_case(domain))
863
code = '\n'.join(f'from . import {d}' for d in sorted(dependencies))
864
865
return code
866
867
def generate_sphinx(self):
868
'''
869
Generate a Sphinx document for this domain.
870
'''
871
docs = self.domain + '\n'
872
docs += '=' * len(self.domain) + '\n\n'
873
if self.description:
874
docs += f'{self.description}\n\n'
875
if self.experimental:
876
docs += '*This CDP domain is experimental.*\n\n'
877
docs += f'.. module:: cdp.{self.module}\n\n'
878
docs += '* Types_\n* Commands_\n* Events_\n\n'
879
880
docs += 'Types\n-----\n\n'
881
if self.types:
882
docs += dedent('''\
883
Generally, you do not need to instantiate CDP types
884
yourself. Instead, the API creates objects for you as return
885
values from commands, and then you can use those objects as
886
arguments to other commands.
887
''')
888
else:
889
docs += '*There are no types in this module.*\n'
890
for type in self.types:
891
docs += f'\n.. autoclass:: {type.id}\n'
892
docs += ' :members:\n'
893
docs += ' :undoc-members:\n'
894
docs += ' :exclude-members: from_json, to_json\n'
895
896
docs += '\nCommands\n--------\n\n'
897
if self.commands:
898
docs += dedent('''\
899
Each command is a generator function. The return
900
type ``Generator[x, y, z]`` indicates that the generator
901
*yields* arguments of type ``x``, it must be resumed with
902
an argument of type ``y``, and it returns type ``z``. In
903
this library, types ``x`` and ``y`` are the same for all
904
commands, and ``z`` is the return type you should pay attention
905
to. For more information, see
906
:ref:`Getting Started: Commands <getting-started-commands>`.
907
''')
908
else:
909
docs += '*There are no types in this module.*\n'
910
for command in sorted(self.commands, key=operator.attrgetter('py_name')):
911
docs += f'\n.. autofunction:: {command.py_name}\n'
912
913
docs += '\nEvents\n------\n\n'
914
if self.events:
915
docs += dedent('''\
916
Generally, you do not need to instantiate CDP events
917
yourself. Instead, the API creates events for you and then
918
you use the event\'s attributes.
919
''')
920
else:
921
docs += '*There are no events in this module.*\n'
922
for event in self.events:
923
docs += f'\n.. autoclass:: {event.py_name}\n'
924
docs += ' :members:\n'
925
docs += ' :undoc-members:\n'
926
docs += ' :exclude-members: from_json, to_json\n'
927
928
return docs
929
930
931
def parse(json_path, output_path):
932
'''
933
Parse JSON protocol description and return domain objects.
934
:param Path json_path: path to a JSON CDP schema
935
:param Path output_path: a directory path to create the modules in
936
:returns: a list of CDP domain objects
937
'''
938
global current_version
939
with open(json_path, encoding="utf-8") as json_file:
940
schema = json.load(json_file)
941
version = schema['version']
942
assert (version['major'], version['minor']) == ('1', '3')
943
current_version = f'{version["major"]}.{version["minor"]}'
944
domains = []
945
for domain in schema['domains']:
946
domains.append(CdpDomain.from_json(domain))
947
return domains
948
949
950
def generate_init(init_path, domains):
951
'''
952
Generate an ``__init__.py`` that exports the specified modules.
953
:param Path init_path: a file path to create the init file in
954
:param list[tuple] modules: a list of modules each represented as tuples
955
of (name, list_of_exported_symbols)
956
'''
957
with open(init_path, "w", encoding="utf-8") as init_file:
958
init_file.write(INIT_HEADER)
959
for domain in domains:
960
init_file.write(f'from . import {domain.module}\n')
961
init_file.write('from . import util\n\n')
962
963
964
def generate_docs(docs_path, domains):
965
'''
966
Generate Sphinx documents for each domain.
967
'''
968
logger.info('Generating Sphinx documents')
969
970
# Remove generated documents
971
for subpath in docs_path.iterdir():
972
subpath.unlink()
973
974
# Generate document for each domain
975
for domain in domains:
976
doc = docs_path / f'{domain.module}.rst'
977
with doc.open('w') as f:
978
f.write(domain.generate_sphinx())
979
980
981
def main(browser_protocol_path, js_protocol_path, output_path):
982
''' Main entry point. '''
983
output_path = Path(output_path).resolve()
984
json_paths = [
985
browser_protocol_path,
986
js_protocol_path,
987
]
988
989
# Generate util.py
990
util_path = output_path / "util.py"
991
with util_path.open('w') as util_file:
992
util_file.write(UTIL_PY)
993
994
# Remove generated code
995
for subpath in output_path.iterdir():
996
if subpath.is_file() and subpath.name not in ('py.typed', 'util.py'):
997
subpath.unlink()
998
999
# Parse domains
1000
domains = []
1001
for json_path in json_paths:
1002
logger.info('Parsing JSON file %s', json_path)
1003
domains.extend(parse(json_path, output_path))
1004
domains.sort(key=operator.attrgetter('domain'))
1005
1006
# Patch up CDP errors. It's easier to patch that here than it is to modify
1007
# the generator code.
1008
# 1. DOM includes an erroneous $ref that refers to itself.
1009
# 2. Page includes an event with an extraneous backtick in the description.
1010
for domain in domains:
1011
if domain.domain == 'DOM':
1012
for cmd in domain.commands:
1013
if cmd.name == 'resolveNode':
1014
# Patch 1
1015
cmd.parameters[1].ref = 'BackendNodeId'
1016
elif domain.domain == 'Page':
1017
for event in domain.events:
1018
if event.name == 'screencastVisibilityChanged':
1019
# Patch 2
1020
event.description = event.description.replace('`', '')
1021
1022
for domain in domains:
1023
logger.info('Generating module: %s → %s.py', domain.domain,
1024
domain.module)
1025
module_path = output_path / f'{domain.module}.py'
1026
with module_path.open('w') as module_file:
1027
module_file.write(domain.generate_code())
1028
1029
init_path = output_path / '__init__.py'
1030
generate_init(init_path, domains)
1031
1032
# Not generating the docs as we don't want people to directly
1033
# Use the CDP APIs
1034
# docs_path = here.parent / 'docs' / 'api'
1035
# generate_docs(docs_path, domains)
1036
1037
py_typed_path = output_path / 'py.typed'
1038
py_typed_path.touch()
1039
1040
1041
if __name__ == '__main__':
1042
import sys
1043
assert sys.version_info >= (3, 7), "To generate the CDP code requires python 3.7 or later"
1044
args = sys.argv[1:]
1045
main(*args)
1046
1047