purecdp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- purecdp/__init__.py +64 -0
- purecdp/_b64.py +24 -0
- purecdp/_generator/__init__.py +10 -0
- purecdp/_generator/__main__.py +53 -0
- purecdp/_generator/emit.py +532 -0
- purecdp/_generator/model.py +175 -0
- purecdp/_json.py +45 -0
- purecdp/_loop.py +53 -0
- purecdp/_shared.py +51 -0
- purecdp/browser.py +363 -0
- purecdp/connection.py +513 -0
- purecdp/discovery.py +50 -0
- purecdp/engine.py +192 -0
- purecdp/errors.py +48 -0
- purecdp/mcp.py +233 -0
- purecdp/protocol/__init__.py +156 -0
- purecdp/protocol/accessibility.py +585 -0
- purecdp/protocol/ads.py +103 -0
- purecdp/protocol/animation.py +385 -0
- purecdp/protocol/audits.py +1925 -0
- purecdp/protocol/autofill.py +250 -0
- purecdp/protocol/background_service.py +177 -0
- purecdp/protocol/bluetooth_emulation.py +520 -0
- purecdp/protocol/browser.py +670 -0
- purecdp/protocol/cache_storage.py +262 -0
- purecdp/protocol/cast.py +131 -0
- purecdp/protocol/console.py +87 -0
- purecdp/protocol/crash_report_context.py +50 -0
- purecdp/protocol/css.py +2336 -0
- purecdp/protocol/debugger.py +1164 -0
- purecdp/protocol/device_access.py +112 -0
- purecdp/protocol/device_orientation.py +36 -0
- purecdp/protocol/digital_credentials.py +57 -0
- purecdp/protocol/dom.py +1869 -0
- purecdp/protocol/dom_debugger.py +249 -0
- purecdp/protocol/dom_snapshot.py +734 -0
- purecdp/protocol/dom_storage.py +186 -0
- purecdp/protocol/emulation.py +1443 -0
- purecdp/protocol/event_breakpoints.py +46 -0
- purecdp/protocol/extensions.py +189 -0
- purecdp/protocol/fed_cm.py +234 -0
- purecdp/protocol/fetch.py +433 -0
- purecdp/protocol/file_system.py +103 -0
- purecdp/protocol/headless_experimental.py +95 -0
- purecdp/protocol/heap_profiler.py +317 -0
- purecdp/protocol/indexed_db.py +447 -0
- purecdp/protocol/input.py +610 -0
- purecdp/protocol/inspector.py +70 -0
- purecdp/protocol/io.py +83 -0
- purecdp/protocol/layer_tree.py +379 -0
- purecdp/protocol/log.py +150 -0
- purecdp/protocol/media.py +271 -0
- purecdp/protocol/memory.py +237 -0
- purecdp/protocol/network.py +4730 -0
- purecdp/protocol/overlay.py +1282 -0
- purecdp/protocol/page.py +3477 -0
- purecdp/protocol/performance.py +88 -0
- purecdp/protocol/performance_timeline.py +176 -0
- purecdp/protocol/preload.py +518 -0
- purecdp/protocol/profiler.py +340 -0
- purecdp/protocol/pwa.py +234 -0
- purecdp/protocol/runtime.py +1344 -0
- purecdp/protocol/schema.py +46 -0
- purecdp/protocol/security.py +448 -0
- purecdp/protocol/service_worker.py +309 -0
- purecdp/protocol/smart_card_emulation.py +801 -0
- purecdp/protocol/storage.py +1074 -0
- purecdp/protocol/system_info.py +277 -0
- purecdp/protocol/target.py +712 -0
- purecdp/protocol/tethering.py +52 -0
- purecdp/protocol/tracing.py +339 -0
- purecdp/protocol/web_audio.py +524 -0
- purecdp/protocol/web_authn.py +475 -0
- purecdp/protocol/web_mcp.py +245 -0
- purecdp/py.typed +0 -0
- purecdp/testing/__init__.py +62 -0
- purecdp/testing/_agent.py +241 -0
- purecdp/testing/element.py +399 -0
- purecdp/testing/frame.py +167 -0
- purecdp/testing/human.py +189 -0
- purecdp/testing/intercept.py +162 -0
- purecdp/testing/page.py +1092 -0
- purecdp/testing/pytest_plugin.py +79 -0
- purecdp/testing/recorder.py +155 -0
- purecdp/testing/snapshot.py +199 -0
- purecdp/testing/sse.py +85 -0
- purecdp/testing/stealth.py +338 -0
- purecdp/testing/testcase.py +55 -0
- purecdp/transport/__init__.py +10 -0
- purecdp/transport/pipe.py +142 -0
- purecdp/transport/websocket.py +214 -0
- purecdp-0.1.0.dist-info/METADATA +193 -0
- purecdp-0.1.0.dist-info/RECORD +96 -0
- purecdp-0.1.0.dist-info/WHEEL +5 -0
- purecdp-0.1.0.dist-info/entry_points.txt +2 -0
- purecdp-0.1.0.dist-info/top_level.txt +1 -0
purecdp/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'''Pure-Python Chrome DevTools Protocol library.
|
|
2
|
+
|
|
3
|
+
Layers (see DESIGN.md):
|
|
4
|
+
- purecdp.protocol — generated typed bindings, one module per CDP domain
|
|
5
|
+
- purecdp.engine — sans-I/O protocol engine (strings in, typed happenings out)
|
|
6
|
+
- purecdp.connection — asyncio Connection/Session over an abstract Transport
|
|
7
|
+
- purecdp.transport — stdlib websocket + pipe transports
|
|
8
|
+
- purecdp.browser — find/launch/clean-up a debuggable browser
|
|
9
|
+
- purecdp.discovery — /json/version, /json/list HTTP endpoints
|
|
10
|
+
- purecdp.errors — exception types
|
|
11
|
+
- purecdp._shared — support types shared by generated code and the engine
|
|
12
|
+
'''
|
|
13
|
+
|
|
14
|
+
from ._loop import run
|
|
15
|
+
from .browser import (
|
|
16
|
+
STEALTH_ARGS,
|
|
17
|
+
Browser,
|
|
18
|
+
BrowserContext,
|
|
19
|
+
close_page,
|
|
20
|
+
connect,
|
|
21
|
+
find_browser,
|
|
22
|
+
launch,
|
|
23
|
+
new_context,
|
|
24
|
+
new_page,
|
|
25
|
+
)
|
|
26
|
+
from .connection import Connection, EventStream, Session, Transport
|
|
27
|
+
from .errors import (
|
|
28
|
+
BrowserLaunchError,
|
|
29
|
+
CDPConnectionClosed,
|
|
30
|
+
CDPError,
|
|
31
|
+
CDPProtocolError,
|
|
32
|
+
CDPSessionClosed,
|
|
33
|
+
CDPTransportError,
|
|
34
|
+
ProtocolDriftWarning,
|
|
35
|
+
)
|
|
36
|
+
from .transport import PipeTransport, WebSocketTransport
|
|
37
|
+
|
|
38
|
+
__version__ = '0.1.0'
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
'Connection',
|
|
42
|
+
'Session',
|
|
43
|
+
'EventStream',
|
|
44
|
+
'Transport',
|
|
45
|
+
'WebSocketTransport',
|
|
46
|
+
'PipeTransport',
|
|
47
|
+
'Browser',
|
|
48
|
+
'BrowserContext',
|
|
49
|
+
'launch',
|
|
50
|
+
'connect',
|
|
51
|
+
'find_browser',
|
|
52
|
+
'new_context',
|
|
53
|
+
'new_page',
|
|
54
|
+
'close_page',
|
|
55
|
+
'STEALTH_ARGS',
|
|
56
|
+
'run',
|
|
57
|
+
'CDPError',
|
|
58
|
+
'CDPProtocolError',
|
|
59
|
+
'CDPConnectionClosed',
|
|
60
|
+
'CDPSessionClosed',
|
|
61
|
+
'CDPTransportError',
|
|
62
|
+
'BrowserLaunchError',
|
|
63
|
+
'ProtocolDriftWarning',
|
|
64
|
+
]
|
purecdp/_b64.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'''base64 backend: pybase64 (SIMD) when installed, stdlib base64 otherwise.
|
|
2
|
+
|
|
3
|
+
CDP moves base64 in bulk — screenshots, PDFs, and recorded response bodies —
|
|
4
|
+
and pybase64 decodes them ~3-4x faster than the stdlib. Optional: purecdp
|
|
5
|
+
stays zero-dependency; these are drop-in replacements (identical signatures)
|
|
6
|
+
used only where large blobs are decoded/encoded.
|
|
7
|
+
'''
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import pybase64 as _backend
|
|
13
|
+
|
|
14
|
+
HAVE_PYBASE64 = True
|
|
15
|
+
except ImportError: # pragma: no cover - stdlib path exercised in CI
|
|
16
|
+
import base64 as _backend
|
|
17
|
+
|
|
18
|
+
HAVE_PYBASE64 = False
|
|
19
|
+
|
|
20
|
+
b64decode = _backend.b64decode
|
|
21
|
+
b64encode = _backend.b64encode
|
|
22
|
+
|
|
23
|
+
#: Name of the active backend, for diagnostics.
|
|
24
|
+
BACKEND = 'pybase64' if HAVE_PYBASE64 else 'base64'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'''Code generator: devtools-protocol JSON specs -> purecdp.protocol modules.
|
|
2
|
+
|
|
3
|
+
Dev-time tool only - not imported by the runtime library. Run with:
|
|
4
|
+
|
|
5
|
+
python -m purecdp._generator [--spec DIR] [--out DIR]
|
|
6
|
+
|
|
7
|
+
CDP bindings are generated from the Chrome DevTools Protocol JSON (BSD-3-Clause,
|
|
8
|
+
© Google), via the hyperiongray python-chrome-devtools-protocol (MIT) convention"
|
|
9
|
+
|
|
10
|
+
'''
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'''CLI: python -m purecdp._generator [--spec DIR] [--out DIR]'''
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import pathlib
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from . import emit, model
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def repo_root() -> pathlib.Path:
|
|
13
|
+
# src/purecdp/_generator/__main__.py -> repo root is three levels above src/
|
|
14
|
+
return pathlib.Path(__file__).resolve().parents[3]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv: list[str] | None = None) -> int:
|
|
18
|
+
root = repo_root()
|
|
19
|
+
ap = argparse.ArgumentParser(prog='purecdp._generator')
|
|
20
|
+
ap.add_argument('--spec', type=pathlib.Path, default=root / 'spec',
|
|
21
|
+
help='directory containing browser_protocol.json, js_protocol.json, PIN')
|
|
22
|
+
ap.add_argument('--out', type=pathlib.Path,
|
|
23
|
+
default=root / 'src' / 'purecdp' / 'protocol',
|
|
24
|
+
help='output package directory (existing *.py files are removed)')
|
|
25
|
+
args = ap.parse_args(argv)
|
|
26
|
+
|
|
27
|
+
spec_files = [args.spec / 'browser_protocol.json', args.spec / 'js_protocol.json']
|
|
28
|
+
for f in spec_files:
|
|
29
|
+
if not f.is_file():
|
|
30
|
+
ap.error(f'spec file not found: {f}')
|
|
31
|
+
pin_file = args.spec / 'PIN'
|
|
32
|
+
pin = pin_file.read_text().split()[0][:12] if pin_file.is_file() else 'unpinned'
|
|
33
|
+
|
|
34
|
+
spec = model.load(spec_files)
|
|
35
|
+
files = emit.emit_all(spec, pin)
|
|
36
|
+
|
|
37
|
+
args.out.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
for stale in args.out.glob('*.py'):
|
|
39
|
+
if stale.name not in files:
|
|
40
|
+
stale.unlink()
|
|
41
|
+
changed = 0
|
|
42
|
+
for name, source in sorted(files.items()):
|
|
43
|
+
path = args.out / name
|
|
44
|
+
if not path.is_file() or path.read_text(encoding='utf-8') != source:
|
|
45
|
+
path.write_text(source, encoding='utf-8')
|
|
46
|
+
changed += 1
|
|
47
|
+
print(f'{len(spec.domains)} domains -> {len(files)} files '
|
|
48
|
+
f'({changed} written) in {args.out}')
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == '__main__':
|
|
53
|
+
sys.exit(main())
|
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
'''Emit Python modules (one per CDP domain) from the parsed spec IR.'''
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import keyword
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from .model import Command, Domain, Event, Property, Spec, TypeDecl, TypeRef
|
|
9
|
+
|
|
10
|
+
# Names that generated field/param identifiers must never shadow.
|
|
11
|
+
BASE_RESERVED = frozenset(keyword.kwlist) | {
|
|
12
|
+
'json', 'params', 'cmd', 'self', 'cls',
|
|
13
|
+
'typing', 'enum', 'dataclass', 'event_class', 'T_JSON_DICT',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
SCALAR_BASES = {'string': 'str', 'integer': 'int', 'number': 'float', 'binary': 'str'}
|
|
17
|
+
PRIMITIVE_ANN = {
|
|
18
|
+
'string': 'str', 'integer': 'int', 'number': 'float', 'boolean': 'bool',
|
|
19
|
+
'any': 'typing.Any', 'object': 'T_JSON_DICT', 'binary': 'str',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def snake(name: str) -> str:
|
|
24
|
+
'''CamelCase / camelCase (incl. acronym runs) -> snake_case.'''
|
|
25
|
+
s = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1_\2', name)
|
|
26
|
+
s = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s)
|
|
27
|
+
return s.lower()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def module_name(domain: str) -> str:
|
|
31
|
+
return snake(domain)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def enum_member(value: str) -> str:
|
|
35
|
+
s = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', value)
|
|
36
|
+
s = re.sub(r'[^A-Za-z0-9]+', '_', s).upper().strip('_')
|
|
37
|
+
if not s:
|
|
38
|
+
raise ValueError(f'cannot derive enum member name from {value!r}')
|
|
39
|
+
if s[0].isdigit():
|
|
40
|
+
s = '_' + s
|
|
41
|
+
return s
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cap_first(name: str) -> str:
|
|
45
|
+
return name[0].upper() + name[1:]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def escape_doc(text: str) -> str:
|
|
49
|
+
return text.replace('\\', '\\\\').replace("'''", '"""')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DomainEmitter:
|
|
53
|
+
def __init__(self, domain: Domain, spec: Spec, pin: str):
|
|
54
|
+
self.domain = domain
|
|
55
|
+
self.spec = spec
|
|
56
|
+
self.pin = pin
|
|
57
|
+
self.imports = self._collect_imports()
|
|
58
|
+
self.reserved = BASE_RESERVED | self.imports
|
|
59
|
+
|
|
60
|
+
# ---------- naming ----------
|
|
61
|
+
|
|
62
|
+
def safe(self, name: str) -> str:
|
|
63
|
+
'''snake_case identifier for a CDP property/parameter name.'''
|
|
64
|
+
ident = snake(name)
|
|
65
|
+
if ident in self.reserved:
|
|
66
|
+
ident += '_'
|
|
67
|
+
return ident
|
|
68
|
+
|
|
69
|
+
def qualify(self, ref: str) -> str:
|
|
70
|
+
'''Python name for a $ref, from the perspective of this domain.'''
|
|
71
|
+
if '.' in ref:
|
|
72
|
+
dom, name = ref.split('.', 1)
|
|
73
|
+
if dom == self.domain.name:
|
|
74
|
+
return name
|
|
75
|
+
return f'{module_name(dom)}.{name}'
|
|
76
|
+
return ref
|
|
77
|
+
|
|
78
|
+
def _walk_typerefs(self):
|
|
79
|
+
def walk(t: TypeRef):
|
|
80
|
+
yield t
|
|
81
|
+
if t.items is not None:
|
|
82
|
+
yield from walk(t.items)
|
|
83
|
+
|
|
84
|
+
for td in self.domain.types:
|
|
85
|
+
if td.items is not None:
|
|
86
|
+
yield from walk(td.items)
|
|
87
|
+
for p in td.properties or ():
|
|
88
|
+
yield from walk(p.type)
|
|
89
|
+
for c in self.domain.commands:
|
|
90
|
+
for p in list(c.parameters) + list(c.returns):
|
|
91
|
+
yield from walk(p.type)
|
|
92
|
+
for e in self.domain.events:
|
|
93
|
+
for p in e.parameters:
|
|
94
|
+
yield from walk(p.type)
|
|
95
|
+
|
|
96
|
+
def _collect_imports(self) -> set[str]:
|
|
97
|
+
mods: set[str] = set()
|
|
98
|
+
for t in self._walk_typerefs():
|
|
99
|
+
if t.kind == 'ref' and '.' in t.ref:
|
|
100
|
+
dom = t.ref.split('.', 1)[0]
|
|
101
|
+
if dom != self.domain.name:
|
|
102
|
+
mods.add(module_name(dom))
|
|
103
|
+
return mods
|
|
104
|
+
|
|
105
|
+
# ---------- type expressions ----------
|
|
106
|
+
|
|
107
|
+
def ann(self, t: TypeRef) -> str:
|
|
108
|
+
if t.kind == 'ref':
|
|
109
|
+
return self.qualify(t.ref)
|
|
110
|
+
if t.kind == 'array':
|
|
111
|
+
return f'list[{self.ann(t.items)}]'
|
|
112
|
+
return PRIMITIVE_ANN[t.kind]
|
|
113
|
+
|
|
114
|
+
def from_expr(self, t: TypeRef, var: str) -> str:
|
|
115
|
+
if t.kind == 'ref':
|
|
116
|
+
return f'{self.qualify(t.ref)}.from_json({var})'
|
|
117
|
+
if t.kind == 'array':
|
|
118
|
+
inner = self.from_expr(t.items, 'i')
|
|
119
|
+
if inner == 'i':
|
|
120
|
+
return f'list({var})'
|
|
121
|
+
return f'[{inner} for i in {var}]'
|
|
122
|
+
if t.kind == 'number':
|
|
123
|
+
return f'float({var})'
|
|
124
|
+
return var
|
|
125
|
+
|
|
126
|
+
def to_expr(self, t: TypeRef, var: str) -> str:
|
|
127
|
+
if t.kind == 'ref':
|
|
128
|
+
return f'{var}.to_json()'
|
|
129
|
+
if t.kind == 'array':
|
|
130
|
+
inner = self.to_expr(t.items, 'i')
|
|
131
|
+
if inner == 'i':
|
|
132
|
+
return f'list({var})'
|
|
133
|
+
return f'[{inner} for i in {var}]'
|
|
134
|
+
return var
|
|
135
|
+
|
|
136
|
+
# ---------- docstrings ----------
|
|
137
|
+
|
|
138
|
+
def _doc_lines(self, description: str | None, *, deprecated=False,
|
|
139
|
+
experimental=False, extra: list[str] | None = None) -> list[str]:
|
|
140
|
+
lines: list[str] = []
|
|
141
|
+
if description:
|
|
142
|
+
lines += escape_doc(description).splitlines()
|
|
143
|
+
flags = []
|
|
144
|
+
if experimental:
|
|
145
|
+
flags.append('**EXPERIMENTAL**')
|
|
146
|
+
if deprecated:
|
|
147
|
+
flags.append('**DEPRECATED**')
|
|
148
|
+
if flags:
|
|
149
|
+
if lines:
|
|
150
|
+
lines.append('')
|
|
151
|
+
lines.append(' '.join(flags))
|
|
152
|
+
if extra:
|
|
153
|
+
if lines:
|
|
154
|
+
lines.append('')
|
|
155
|
+
lines += extra
|
|
156
|
+
return lines
|
|
157
|
+
|
|
158
|
+
def _docstring(self, indent: str, lines: list[str]) -> list[str]:
|
|
159
|
+
if not lines:
|
|
160
|
+
return []
|
|
161
|
+
if len(lines) == 1 and not lines[0].endswith("'"):
|
|
162
|
+
return [f"{indent}'''{lines[0]}'''"]
|
|
163
|
+
out = [f"{indent}'''{lines[0]}"]
|
|
164
|
+
out += [f'{indent}{ln}'.rstrip() for ln in lines[1:]]
|
|
165
|
+
out.append(f"{indent}'''")
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
def _param_doc(self, p: Property) -> str:
|
|
169
|
+
bits = []
|
|
170
|
+
if p.optional:
|
|
171
|
+
bits.append('*(Optional)*')
|
|
172
|
+
if p.description:
|
|
173
|
+
bits.append(' '.join(escape_doc(p.description).splitlines()))
|
|
174
|
+
if p.type.enum:
|
|
175
|
+
bits.append('Allowed values: ' + ', '.join(p.type.enum))
|
|
176
|
+
if p.experimental:
|
|
177
|
+
bits.append('*(EXPERIMENTAL)*')
|
|
178
|
+
if p.deprecated:
|
|
179
|
+
bits.append('*(DEPRECATED)*')
|
|
180
|
+
return ' '.join(bits)
|
|
181
|
+
|
|
182
|
+
# ---------- named types ----------
|
|
183
|
+
|
|
184
|
+
def emit_type(self, td: TypeDecl) -> list[str]:
|
|
185
|
+
if td.kind == 'string' and td.enum:
|
|
186
|
+
return self._emit_enum(td)
|
|
187
|
+
if td.kind in SCALAR_BASES:
|
|
188
|
+
return self._emit_scalar(td)
|
|
189
|
+
if td.kind == 'array':
|
|
190
|
+
return self._emit_named_array(td)
|
|
191
|
+
if td.kind == 'object':
|
|
192
|
+
if td.properties is None:
|
|
193
|
+
return self._emit_dict_type(td)
|
|
194
|
+
return self._emit_dataclass(td)
|
|
195
|
+
if td.kind == 'any':
|
|
196
|
+
return [f'{td.id} = typing.Any']
|
|
197
|
+
raise ValueError(f'cannot emit named type {self.domain.name}.{td.id} ({td.kind})')
|
|
198
|
+
|
|
199
|
+
def _type_doc(self, td: TypeDecl) -> list[str]:
|
|
200
|
+
return self._docstring(' ', self._doc_lines(
|
|
201
|
+
td.description, deprecated=td.deprecated, experimental=td.experimental))
|
|
202
|
+
|
|
203
|
+
def _emit_enum(self, td: TypeDecl) -> list[str]:
|
|
204
|
+
out = [f'class {td.id}(enum.StrEnum):']
|
|
205
|
+
out += self._type_doc(td)
|
|
206
|
+
members = {}
|
|
207
|
+
for value in td.enum:
|
|
208
|
+
name = enum_member(value)
|
|
209
|
+
if name in members:
|
|
210
|
+
raise ValueError(f'enum member collision in {self.domain.name}.{td.id}: {name}')
|
|
211
|
+
members[name] = value
|
|
212
|
+
out.append(f' {name} = {value!r}')
|
|
213
|
+
out += [
|
|
214
|
+
'',
|
|
215
|
+
' def to_json(self) -> str:',
|
|
216
|
+
' return self.value',
|
|
217
|
+
'',
|
|
218
|
+
' @classmethod',
|
|
219
|
+
f' def from_json(cls, json: str) -> {td.id}:',
|
|
220
|
+
' return cls(json)',
|
|
221
|
+
]
|
|
222
|
+
return out
|
|
223
|
+
|
|
224
|
+
def _emit_scalar(self, td: TypeDecl) -> list[str]:
|
|
225
|
+
base = SCALAR_BASES[td.kind]
|
|
226
|
+
out = [f'class {td.id}({base}):']
|
|
227
|
+
out += self._type_doc(td)
|
|
228
|
+
out += [
|
|
229
|
+
f' def to_json(self) -> {base}:',
|
|
230
|
+
f' return {base}(self)',
|
|
231
|
+
'',
|
|
232
|
+
' @classmethod',
|
|
233
|
+
f' def from_json(cls, json: {base}) -> {td.id}:',
|
|
234
|
+
' return cls(json)',
|
|
235
|
+
'',
|
|
236
|
+
' def __repr__(self) -> str:',
|
|
237
|
+
f" return f'{td.id}({{{base}.__repr__(self)}})'",
|
|
238
|
+
]
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
def _emit_named_array(self, td: TypeDecl) -> list[str]:
|
|
242
|
+
out = [f'class {td.id}(list):']
|
|
243
|
+
out += self._type_doc(td)
|
|
244
|
+
out += [
|
|
245
|
+
' def to_json(self) -> list:',
|
|
246
|
+
f' return {self.to_expr(TypeRef(kind='array', items=td.items), 'self')}',
|
|
247
|
+
'',
|
|
248
|
+
' @classmethod',
|
|
249
|
+
f' def from_json(cls, json: list) -> {td.id}:',
|
|
250
|
+
f' return cls({self.from_expr(TypeRef(kind='array', items=td.items), 'json')})',
|
|
251
|
+
'',
|
|
252
|
+
' def __repr__(self) -> str:',
|
|
253
|
+
f" return f'{td.id}({{list.__repr__(self)}})'",
|
|
254
|
+
]
|
|
255
|
+
return out
|
|
256
|
+
|
|
257
|
+
def _emit_dict_type(self, td: TypeDecl) -> list[str]:
|
|
258
|
+
out = [f'class {td.id}(dict):']
|
|
259
|
+
out += self._type_doc(td)
|
|
260
|
+
out += [
|
|
261
|
+
' def to_json(self) -> T_JSON_DICT:',
|
|
262
|
+
' return dict(self)',
|
|
263
|
+
'',
|
|
264
|
+
' @classmethod',
|
|
265
|
+
f' def from_json(cls, json: T_JSON_DICT) -> {td.id}:',
|
|
266
|
+
' return cls(json)',
|
|
267
|
+
'',
|
|
268
|
+
' def __repr__(self) -> str:',
|
|
269
|
+
f" return f'{td.id}({{dict.__repr__(self)}})'",
|
|
270
|
+
]
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def _ordered(props: tuple[Property, ...]) -> list[Property]:
|
|
275
|
+
return [p for p in props if not p.optional] + [p for p in props if p.optional]
|
|
276
|
+
|
|
277
|
+
def _emit_fields(self, props: list[Property]) -> list[str]:
|
|
278
|
+
out = []
|
|
279
|
+
for p in props:
|
|
280
|
+
fname = self.safe(p.name)
|
|
281
|
+
doc = self._param_doc(p)
|
|
282
|
+
if doc:
|
|
283
|
+
out.append(f' #: {doc}')
|
|
284
|
+
if p.optional:
|
|
285
|
+
out.append(f' {fname}: {self.ann(p.type)} | None = None')
|
|
286
|
+
else:
|
|
287
|
+
out.append(f' {fname}: {self.ann(p.type)}')
|
|
288
|
+
return out
|
|
289
|
+
|
|
290
|
+
def _emit_from_json(self, cls_name: str, props: list[Property]) -> list[str]:
|
|
291
|
+
out = [
|
|
292
|
+
' @classmethod',
|
|
293
|
+
f' def from_json(cls, json: T_JSON_DICT) -> {cls_name}:',
|
|
294
|
+
]
|
|
295
|
+
if not props:
|
|
296
|
+
out.append(' return cls()')
|
|
297
|
+
return out
|
|
298
|
+
out.append(' return cls(')
|
|
299
|
+
for p in props:
|
|
300
|
+
fname = self.safe(p.name)
|
|
301
|
+
expr = self.from_expr(p.type, f'json[{p.name!r}]')
|
|
302
|
+
if p.optional:
|
|
303
|
+
expr = f'{expr} if json.get({p.name!r}) is not None else None'
|
|
304
|
+
out.append(f' {fname}={expr},')
|
|
305
|
+
out.append(' )')
|
|
306
|
+
return out
|
|
307
|
+
|
|
308
|
+
def _emit_dataclass(self, td: TypeDecl) -> list[str]:
|
|
309
|
+
props = self._ordered(td.properties)
|
|
310
|
+
out = ['@dataclass(slots=True)', f'class {td.id}:']
|
|
311
|
+
out += self._type_doc(td)
|
|
312
|
+
out += self._emit_fields(props)
|
|
313
|
+
if td.properties or not out[-1].startswith(" '''"):
|
|
314
|
+
out.append('')
|
|
315
|
+
# to_json
|
|
316
|
+
out += [' def to_json(self) -> T_JSON_DICT:',
|
|
317
|
+
' json: T_JSON_DICT = {}']
|
|
318
|
+
for p in props:
|
|
319
|
+
fname = self.safe(p.name)
|
|
320
|
+
expr = self.to_expr(p.type, f'self.{fname}')
|
|
321
|
+
if p.optional:
|
|
322
|
+
out.append(f' if self.{fname} is not None:')
|
|
323
|
+
out.append(f' json[{p.name!r}] = {expr}')
|
|
324
|
+
else:
|
|
325
|
+
out.append(f' json[{p.name!r}] = {expr}')
|
|
326
|
+
out.append(' return json')
|
|
327
|
+
out.append('')
|
|
328
|
+
out += self._emit_from_json(td.id, props)
|
|
329
|
+
return out
|
|
330
|
+
|
|
331
|
+
# ---------- commands ----------
|
|
332
|
+
|
|
333
|
+
def emit_command(self, c: Command) -> list[str]:
|
|
334
|
+
fname = self.safe(c.name)
|
|
335
|
+
params = self._ordered(c.parameters)
|
|
336
|
+
sig_parts = []
|
|
337
|
+
for p in params:
|
|
338
|
+
pn = self.safe(p.name)
|
|
339
|
+
if p.optional:
|
|
340
|
+
sig_parts.append(f'{pn}: {self.ann(p.type)} | None = None')
|
|
341
|
+
else:
|
|
342
|
+
sig_parts.append(f'{pn}: {self.ann(p.type)}')
|
|
343
|
+
|
|
344
|
+
returns = list(c.returns)
|
|
345
|
+
if not returns:
|
|
346
|
+
ret_ann = 'None'
|
|
347
|
+
elif len(returns) == 1:
|
|
348
|
+
r = returns[0]
|
|
349
|
+
ret_ann = f'{self.ann(r.type)} | None' if r.optional else self.ann(r.type)
|
|
350
|
+
else:
|
|
351
|
+
anns = [f'{self.ann(r.type)} | None' if r.optional else self.ann(r.type)
|
|
352
|
+
for r in returns]
|
|
353
|
+
ret_ann = 'tuple[' + ', '.join(anns) + ']'
|
|
354
|
+
gen_ann = f'typing.Generator[T_JSON_DICT, T_JSON_DICT, {ret_ann}]'
|
|
355
|
+
|
|
356
|
+
sig = f'def {fname}(' + ', '.join(sig_parts) + f') -> {gen_ann}:'
|
|
357
|
+
if len(sig) > 100 and sig_parts:
|
|
358
|
+
out = [f'def {fname}(']
|
|
359
|
+
out += [f' {part},' for part in sig_parts]
|
|
360
|
+
out.append(f') -> {gen_ann}:')
|
|
361
|
+
else:
|
|
362
|
+
out = [sig]
|
|
363
|
+
|
|
364
|
+
extra = [f':param {self.safe(p.name)}: {self._param_doc(p)}' for p in params]
|
|
365
|
+
if len(returns) == 1:
|
|
366
|
+
extra.append(f':returns: {self._param_doc(returns[0])}')
|
|
367
|
+
elif len(returns) > 1:
|
|
368
|
+
extra.append(':returns: A tuple with the following items:')
|
|
369
|
+
for i, r in enumerate(returns):
|
|
370
|
+
extra.append(f' {i}. **{r.name}** — {self._param_doc(r)}')
|
|
371
|
+
out += self._docstring(' ', self._doc_lines(
|
|
372
|
+
c.description, deprecated=c.deprecated, experimental=c.experimental,
|
|
373
|
+
extra=extra))
|
|
374
|
+
|
|
375
|
+
method = f'{self.domain.name}.{c.name}'
|
|
376
|
+
if params:
|
|
377
|
+
out.append(' params: T_JSON_DICT = {}')
|
|
378
|
+
for p in params:
|
|
379
|
+
expr = self.to_expr(p.type, self.safe(p.name))
|
|
380
|
+
if p.optional:
|
|
381
|
+
out.append(f' if {self.safe(p.name)} is not None:')
|
|
382
|
+
out.append(f' params[{p.name!r}] = {expr}')
|
|
383
|
+
else:
|
|
384
|
+
out.append(f' params[{p.name!r}] = {expr}')
|
|
385
|
+
cmd = f"{{'method': {method!r}, 'params': params}}"
|
|
386
|
+
else:
|
|
387
|
+
cmd = f"{{'method': {method!r}}}"
|
|
388
|
+
|
|
389
|
+
if not returns:
|
|
390
|
+
out.append(f' yield {cmd}')
|
|
391
|
+
else:
|
|
392
|
+
out.append(f' json = yield {cmd}')
|
|
393
|
+
if len(returns) == 1:
|
|
394
|
+
r = returns[0]
|
|
395
|
+
expr = self.from_expr(r.type, f'json[{r.name!r}]')
|
|
396
|
+
if r.optional:
|
|
397
|
+
expr = f'{expr} if json.get({r.name!r}) is not None else None'
|
|
398
|
+
out.append(f' return {expr}')
|
|
399
|
+
else:
|
|
400
|
+
out.append(' return (')
|
|
401
|
+
for r in returns:
|
|
402
|
+
expr = self.from_expr(r.type, f'json[{r.name!r}]')
|
|
403
|
+
if r.optional:
|
|
404
|
+
expr = f'{expr} if json.get({r.name!r}) is not None else None'
|
|
405
|
+
out.append(f' {expr},')
|
|
406
|
+
out.append(' )')
|
|
407
|
+
return out
|
|
408
|
+
|
|
409
|
+
# ---------- events ----------
|
|
410
|
+
|
|
411
|
+
def emit_event(self, e: Event) -> list[str]:
|
|
412
|
+
cls_name = cap_first(e.name)
|
|
413
|
+
method = f'{self.domain.name}.{e.name}'
|
|
414
|
+
props = self._ordered(e.parameters)
|
|
415
|
+
out = [f'@event_class({method!r})', '@dataclass(slots=True)',
|
|
416
|
+
f'class {cls_name}:']
|
|
417
|
+
out += self._docstring(' ', self._doc_lines(
|
|
418
|
+
e.description, deprecated=e.deprecated, experimental=e.experimental))
|
|
419
|
+
out += self._emit_fields(props)
|
|
420
|
+
if props or out[-1].endswith("'''"):
|
|
421
|
+
out.append('')
|
|
422
|
+
out += self._emit_from_json(cls_name, props)
|
|
423
|
+
return out
|
|
424
|
+
|
|
425
|
+
# ---------- module ----------
|
|
426
|
+
|
|
427
|
+
def emit_module(self) -> str:
|
|
428
|
+
d = self.domain
|
|
429
|
+
# class-name collision check (named types vs event classes)
|
|
430
|
+
type_names = {t.id for t in d.types}
|
|
431
|
+
event_names = {cap_first(e.name) for e in d.events}
|
|
432
|
+
clash = type_names & event_names
|
|
433
|
+
if clash:
|
|
434
|
+
raise ValueError(f'class name collision in {d.name}: {clash}')
|
|
435
|
+
|
|
436
|
+
header = [
|
|
437
|
+
f'# DO NOT EDIT — generated by purecdp._generator from {d.name} '
|
|
438
|
+
f'(devtools-protocol {self.pin})',
|
|
439
|
+
]
|
|
440
|
+
doc = self._doc_lines(d.description, deprecated=d.deprecated,
|
|
441
|
+
experimental=d.experimental)
|
|
442
|
+
title = f'CDP domain: {d.name}.'
|
|
443
|
+
header += self._docstring('', [title] + ([''] + doc if doc else []))
|
|
444
|
+
header += [
|
|
445
|
+
'',
|
|
446
|
+
'from __future__ import annotations',
|
|
447
|
+
'',
|
|
448
|
+
'import enum',
|
|
449
|
+
'import typing',
|
|
450
|
+
'from dataclasses import dataclass',
|
|
451
|
+
'',
|
|
452
|
+
'from .._shared import T_JSON_DICT, event_class',
|
|
453
|
+
]
|
|
454
|
+
if self.imports:
|
|
455
|
+
header.append('')
|
|
456
|
+
header.append('from . import ' + ', '.join(sorted(self.imports)))
|
|
457
|
+
|
|
458
|
+
blocks: list[list[str]] = []
|
|
459
|
+
for td in d.types:
|
|
460
|
+
blocks.append(self.emit_type(td))
|
|
461
|
+
for c in d.commands:
|
|
462
|
+
blocks.append(self.emit_command(c))
|
|
463
|
+
for e in d.events:
|
|
464
|
+
blocks.append(self.emit_event(e))
|
|
465
|
+
|
|
466
|
+
body = []
|
|
467
|
+
for b in blocks:
|
|
468
|
+
body += ['', ''] + b
|
|
469
|
+
return '\n'.join(header + body) + '\n'
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def emit_init(spec: Spec, pin: str) -> str:
|
|
473
|
+
mods = sorted(module_name(d.name) for d in spec.domains)
|
|
474
|
+
domain_map = sorted((d.name, module_name(d.name)) for d in spec.domains)
|
|
475
|
+
lines = [
|
|
476
|
+
f'# DO NOT EDIT — generated by purecdp._generator (devtools-protocol {pin})',
|
|
477
|
+
f"'''Generated CDP bindings, protocol version {spec.version}.",
|
|
478
|
+
'',
|
|
479
|
+
'Domain modules load lazily on first attribute access (PEP 562).',
|
|
480
|
+
'Event classes register with purecdp._shared only when their module is',
|
|
481
|
+
'imported; call load_all() to populate the full event registry, or',
|
|
482
|
+
'import_domain() to resolve a single domain by its CDP name.',
|
|
483
|
+
"'''",
|
|
484
|
+
'',
|
|
485
|
+
'from __future__ import annotations',
|
|
486
|
+
'',
|
|
487
|
+
'import importlib',
|
|
488
|
+
'import typing',
|
|
489
|
+
'',
|
|
490
|
+
'__all__ = [',
|
|
491
|
+
]
|
|
492
|
+
lines += [f' {m!r},' for m in mods]
|
|
493
|
+
lines += [
|
|
494
|
+
']',
|
|
495
|
+
'',
|
|
496
|
+
'#: CDP domain name -> module name in this package.',
|
|
497
|
+
'DOMAIN_MODULES: dict[str, str] = {',
|
|
498
|
+
]
|
|
499
|
+
lines += [f' {dom!r}: {mod!r},' for dom, mod in domain_map]
|
|
500
|
+
lines += [
|
|
501
|
+
'}',
|
|
502
|
+
'',
|
|
503
|
+
'',
|
|
504
|
+
'def import_domain(domain: str) -> typing.Any:',
|
|
505
|
+
" '''Import and return the module for a CDP domain name (e.g. 'DOM').",
|
|
506
|
+
'',
|
|
507
|
+
' Raises KeyError for domains not in the pinned spec.',
|
|
508
|
+
" '''",
|
|
509
|
+
" return importlib.import_module('.' + DOMAIN_MODULES[domain], __name__)",
|
|
510
|
+
'',
|
|
511
|
+
'',
|
|
512
|
+
'def __getattr__(name: str) -> typing.Any:',
|
|
513
|
+
' if name in __all__:',
|
|
514
|
+
" return importlib.import_module('.' + name, __name__)",
|
|
515
|
+
" raise AttributeError(f'module {__name__!r} has no attribute {name!r}')",
|
|
516
|
+
'',
|
|
517
|
+
'',
|
|
518
|
+
'def load_all() -> None:',
|
|
519
|
+
" '''Import every domain module (fully populates the event registry).'''",
|
|
520
|
+
' for name in __all__:',
|
|
521
|
+
" importlib.import_module('.' + name, __name__)",
|
|
522
|
+
'',
|
|
523
|
+
]
|
|
524
|
+
return '\n'.join(lines)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def emit_all(spec: Spec, pin: str) -> dict[str, str]:
|
|
528
|
+
'''Return {filename: source} for the whole purecdp.protocol package.'''
|
|
529
|
+
out: dict[str, str] = {'__init__.py': emit_init(spec, pin)}
|
|
530
|
+
for d in spec.domains:
|
|
531
|
+
out[module_name(d.name) + '.py'] = DomainEmitter(d, spec, pin).emit_module()
|
|
532
|
+
return out
|