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