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.
Files changed (96) hide show
  1. purecdp/__init__.py +64 -0
  2. purecdp/_b64.py +24 -0
  3. purecdp/_generator/__init__.py +10 -0
  4. purecdp/_generator/__main__.py +53 -0
  5. purecdp/_generator/emit.py +532 -0
  6. purecdp/_generator/model.py +175 -0
  7. purecdp/_json.py +45 -0
  8. purecdp/_loop.py +53 -0
  9. purecdp/_shared.py +51 -0
  10. purecdp/browser.py +363 -0
  11. purecdp/connection.py +513 -0
  12. purecdp/discovery.py +50 -0
  13. purecdp/engine.py +192 -0
  14. purecdp/errors.py +48 -0
  15. purecdp/mcp.py +233 -0
  16. purecdp/protocol/__init__.py +156 -0
  17. purecdp/protocol/accessibility.py +585 -0
  18. purecdp/protocol/ads.py +103 -0
  19. purecdp/protocol/animation.py +385 -0
  20. purecdp/protocol/audits.py +1925 -0
  21. purecdp/protocol/autofill.py +250 -0
  22. purecdp/protocol/background_service.py +177 -0
  23. purecdp/protocol/bluetooth_emulation.py +520 -0
  24. purecdp/protocol/browser.py +670 -0
  25. purecdp/protocol/cache_storage.py +262 -0
  26. purecdp/protocol/cast.py +131 -0
  27. purecdp/protocol/console.py +87 -0
  28. purecdp/protocol/crash_report_context.py +50 -0
  29. purecdp/protocol/css.py +2336 -0
  30. purecdp/protocol/debugger.py +1164 -0
  31. purecdp/protocol/device_access.py +112 -0
  32. purecdp/protocol/device_orientation.py +36 -0
  33. purecdp/protocol/digital_credentials.py +57 -0
  34. purecdp/protocol/dom.py +1869 -0
  35. purecdp/protocol/dom_debugger.py +249 -0
  36. purecdp/protocol/dom_snapshot.py +734 -0
  37. purecdp/protocol/dom_storage.py +186 -0
  38. purecdp/protocol/emulation.py +1443 -0
  39. purecdp/protocol/event_breakpoints.py +46 -0
  40. purecdp/protocol/extensions.py +189 -0
  41. purecdp/protocol/fed_cm.py +234 -0
  42. purecdp/protocol/fetch.py +433 -0
  43. purecdp/protocol/file_system.py +103 -0
  44. purecdp/protocol/headless_experimental.py +95 -0
  45. purecdp/protocol/heap_profiler.py +317 -0
  46. purecdp/protocol/indexed_db.py +447 -0
  47. purecdp/protocol/input.py +610 -0
  48. purecdp/protocol/inspector.py +70 -0
  49. purecdp/protocol/io.py +83 -0
  50. purecdp/protocol/layer_tree.py +379 -0
  51. purecdp/protocol/log.py +150 -0
  52. purecdp/protocol/media.py +271 -0
  53. purecdp/protocol/memory.py +237 -0
  54. purecdp/protocol/network.py +4730 -0
  55. purecdp/protocol/overlay.py +1282 -0
  56. purecdp/protocol/page.py +3477 -0
  57. purecdp/protocol/performance.py +88 -0
  58. purecdp/protocol/performance_timeline.py +176 -0
  59. purecdp/protocol/preload.py +518 -0
  60. purecdp/protocol/profiler.py +340 -0
  61. purecdp/protocol/pwa.py +234 -0
  62. purecdp/protocol/runtime.py +1344 -0
  63. purecdp/protocol/schema.py +46 -0
  64. purecdp/protocol/security.py +448 -0
  65. purecdp/protocol/service_worker.py +309 -0
  66. purecdp/protocol/smart_card_emulation.py +801 -0
  67. purecdp/protocol/storage.py +1074 -0
  68. purecdp/protocol/system_info.py +277 -0
  69. purecdp/protocol/target.py +712 -0
  70. purecdp/protocol/tethering.py +52 -0
  71. purecdp/protocol/tracing.py +339 -0
  72. purecdp/protocol/web_audio.py +524 -0
  73. purecdp/protocol/web_authn.py +475 -0
  74. purecdp/protocol/web_mcp.py +245 -0
  75. purecdp/py.typed +0 -0
  76. purecdp/testing/__init__.py +62 -0
  77. purecdp/testing/_agent.py +241 -0
  78. purecdp/testing/element.py +399 -0
  79. purecdp/testing/frame.py +167 -0
  80. purecdp/testing/human.py +189 -0
  81. purecdp/testing/intercept.py +162 -0
  82. purecdp/testing/page.py +1092 -0
  83. purecdp/testing/pytest_plugin.py +79 -0
  84. purecdp/testing/recorder.py +155 -0
  85. purecdp/testing/snapshot.py +199 -0
  86. purecdp/testing/sse.py +85 -0
  87. purecdp/testing/stealth.py +338 -0
  88. purecdp/testing/testcase.py +55 -0
  89. purecdp/transport/__init__.py +10 -0
  90. purecdp/transport/pipe.py +142 -0
  91. purecdp/transport/websocket.py +214 -0
  92. purecdp-0.1.0.dist-info/METADATA +193 -0
  93. purecdp-0.1.0.dist-info/RECORD +96 -0
  94. purecdp-0.1.0.dist-info/WHEEL +5 -0
  95. purecdp-0.1.0.dist-info/entry_points.txt +2 -0
  96. 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