solvecdp 0.0.4__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.
solvecdp/core.py ADDED
@@ -0,0 +1,302 @@
1
+ """Source and API details"""
2
+
3
+ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb.
4
+
5
+ # %% auto #0
6
+ __all__ = ['cdp_search', 'js_cdp', 'JsCDP', 'JsSub', 'JsCDPDomain', 'JsCDPMethod', 'AXNode', 'AXTree', 'build_ax_tree',
7
+ 'cdp_yolo']
8
+
9
+ # %% ../nbs/00_core.ipynb #34dc06ce
10
+ from fastcore.utils import *
11
+ import json, asyncio, inspect, base64
12
+ from contextlib import asynccontextmanager
13
+ from dialoghelper import *
14
+
15
+ # %% ../nbs/00_core.ipynb #930c2391
16
+ if '__file__' not in globals():
17
+ _root = Path.cwd()
18
+ if not (Path.cwd()/'pyproject.toml').exists(): _root = _root.parent
19
+ __file__ = _root/'solvecdp'/'core.py'
20
+
21
+ _path = Path(__file__).parent
22
+ _bp = (_path/'browser_protocol.json').read_json()
23
+ _jp = (_path/'js_protocol.json').read_json()
24
+ _cdp_domains = _bp['domains'] + _jp['domains']
25
+
26
+ # %% ../nbs/00_core.ipynb #12182cb2
27
+ def cdp_search(q:str):
28
+ "Search CDP domains and commands by name or description"
29
+ q = q.lower()
30
+ res = []
31
+ for d in _cdp_domains:
32
+ for cmd in d.get('commands', []):
33
+ desc = cmd.get('description', '')
34
+ if q in cmd['name'].lower() or q in desc.lower():
35
+ res.append(f"{d['domain']}.{cmd['name']}: {desc[:120]}")
36
+ for evt in d.get('events', []):
37
+ desc = evt.get('description', '')
38
+ if q in evt['name'].lower() or q in desc.lower():
39
+ res.append(f" evt {d['domain']}.{evt['name']}: {desc[:120]}")
40
+ return '\n'.join(res)
41
+
42
+ # %% ../nbs/00_core.ipynb #504551f1
43
+ async def js_cdp(method, tabId=None, **params):
44
+ res = (await event_get_a('cdp-send', tabId=tabId, method=method, params=params)).result
45
+ return first(res.values()) if res and len(res)==1 else res
46
+
47
+ # %% ../nbs/00_core.ipynb #2bc182b9
48
+ def _lower1(s): return s[0].lower() + s[1:]
49
+ def _upper1(s): return s[0].upper() + s[1:]
50
+
51
+ # %% ../nbs/00_core.ipynb #0b40661b
52
+ class JsCDP:
53
+ "Chrome DevTools Protocol via JS bridge with event support"
54
+ def __init__(self, tid): self.tid,self._sub_id = tid,0
55
+
56
+ @classmethod
57
+ async def new(cls, tid=None, url='about:blank', active=False):
58
+ if tid: await event_get_a('cdp-attach', tabId=tid)
59
+ else:
60
+ r = await event_get_a('cdp-new-tab', url=url, active=active)
61
+ tid = r.result['tabId']
62
+ self = cls(tid)
63
+ await self('Page.enable')
64
+ await self('Network.enable')
65
+ await self('Page.setLifecycleEventsEnabled', enabled=True)
66
+ return self
67
+
68
+ async def __call__(self, method, **params): return await js_cdp(method, tabId=self.tid, **params)
69
+
70
+ def __getattr__(self, domain):
71
+ if domain.startswith('_'): raise AttributeError()
72
+ return JsCDPDomain(self, _upper1(domain))
73
+
74
+ async def close(self): await event_get_a('cdp-close-tab', tabId=self.tid)
75
+ async def detach(self): await event_get_a('cdp-detach', tabId=self.tid)
76
+ async def __aenter__(self): return self
77
+ async def __aexit__(self, *exc): await self.detach()
78
+
79
+ def __del__(self):
80
+ try: asyncio.get_event_loop().create_task(self.detach())
81
+ except Exception: pass
82
+
83
+ # %% ../nbs/00_core.ipynb #1b014de9
84
+ class JsSub:
85
+ def __init__(self, cdp, events): self.cdp,self.events,self.seq = cdp,events,0
86
+ async def __call__(self, timeout=10):
87
+ self.seq += 1
88
+ return await pop_data_a(f'{self.sub_id}:{self.seq}', timeout)
89
+ async def __aenter__(self):
90
+ self.cdp._sub_id += 1
91
+ self.sub_id = f'jsc_{self.cdp.tid}_{self.cdp._sub_id}'
92
+ await event_get_a('cdp-subscribe', tabId=self.cdp.tid, subId=self.sub_id, events=self.events)
93
+ return self
94
+ async def __aexit__(self, *exc): await self.cdp.unsubscribe(self.sub_id)
95
+
96
+ @patch
97
+ async def unsubscribe(self:JsCDP, sub_id): await event_get_a('cdp-unsubscribe', tabId=self.tid, subId=sub_id)
98
+
99
+ @patch
100
+ def on(self:JsCDP, *events): return JsSub(self, events)
101
+
102
+ # %% ../nbs/00_core.ipynb #0af68b93
103
+ _domains = {d['domain']: d for d in _cdp_domains}
104
+
105
+ def _find_cmd(domain, method):
106
+ d = _domains.get(domain, {})
107
+ return first(c for c in d.get('commands', []) if c['name'] == method)
108
+
109
+ def _cmd_sig(cmd):
110
+ _P = inspect.Parameter
111
+ ps = [_P(p['name'], _P.KEYWORD_ONLY, default=None if p.get('optional') else _P.empty) for p in cmd.get('parameters', [])]
112
+ return inspect.Signature(ps)
113
+
114
+ def _cmd_doc(domain, method, cmd):
115
+ doc = f"{domain}.{method}"
116
+ if cmd.get('description'): doc += f" - {cmd['description']}"
117
+ for p in cmd.get('parameters', []):
118
+ opt = ' (optional)' if p.get('optional') else ''
119
+ doc += f"\n {p['name']}{opt}: {p.get('description', '')}"
120
+ return doc
121
+
122
+ # %% ../nbs/00_core.ipynb #ac370497
123
+ class JsCDPDomain:
124
+ def __init__(self, cdp, domain): store_attr()
125
+ def __getattr__(self, method):
126
+ if method.startswith('_'): raise AttributeError()
127
+ return JsCDPMethod(self.cdp, self.domain, method)
128
+ def __dir__(self):
129
+ d = _domains.get(self.domain, {})
130
+ return [c['name'] for c in d.get('commands', [])] + [e['name'] for e in d.get('events', [])]
131
+
132
+ class JsCDPMethod:
133
+ def __init__(self, cdp, domain, method):
134
+ store_attr()
135
+ if (cmd := _find_cmd(domain, method)):
136
+ self.__doc__ = _cmd_doc(domain, method, cmd)
137
+ self.__signature__ = _cmd_sig(cmd)
138
+ async def __call__(self, **kw): return await self.cdp(f'{self.domain}.{self.method}', **kw)
139
+
140
+ # %% ../nbs/00_core.ipynb #39b7647d
141
+ @patch
142
+ async def wait_for(self:JsCDP, expr, timeout=10):
143
+ "Wait for JS expression to be truthy, return its value"
144
+ deadline = asyncio.get_event_loop().time() + timeout
145
+ while asyncio.get_event_loop().time() < deadline:
146
+ r = await self.eval(expr)
147
+ if r: return r
148
+ await asyncio.sleep(0.05)
149
+ raise TimeoutError(f'Timed out waiting for: {expr}')
150
+
151
+ @patch
152
+ async def wait_for_selector(self:JsCDP, sel, timeout=10):
153
+ "Wait for CSS selector to match an element"
154
+ return await self.wait_for(f'!!document.querySelector("{sel}")', timeout)
155
+
156
+ @patch
157
+ async def eval(self:JsCDP, expr):
158
+ res = await self.runtime.evaluate(expression=expr)
159
+ return res['value'] if res else res
160
+
161
+ # %% ../nbs/00_core.ipynb #9814014d
162
+ @patch
163
+ async def wait_for_ready(self:JsCDP, timeout=10, idle_ms=500):
164
+ "Wait until network is idle for idle_ms"
165
+ deadline = asyncio.get_event_loop().time() + timeout
166
+ while asyncio.get_event_loop().time() < deadline:
167
+ async with self.on('Network.requestWillBeSent') as q:
168
+ if not await q(idle_ms/1000): return
169
+ raise TimeoutError('Timed out waiting for network idle')
170
+
171
+ @patch
172
+ async def goto(self:JsCDP, url, **kwargs):
173
+ "Navigate to url and wait for load+idle"
174
+ await self.page.navigate(url=url)
175
+ await self.wait_for_ready(**kwargs)
176
+
177
+ # %% ../nbs/00_core.ipynb #11ceac02
178
+ @patch(as_prop=True)
179
+ async def pages(self:JsCDP):
180
+ targets = (await event_get_a('cdp-get-targets')).result
181
+ return [t for t in targets if t.get('type') == 'page']
182
+
183
+ # %% ../nbs/00_core.ipynb #b71ec061
184
+ @patch
185
+ async def screenshot(self:JsCDP):
186
+ from IPython.display import Image
187
+ b64 = await self.page.captureScreenshot(format='png')
188
+ return Image(base64.b64decode(b64))
189
+
190
+ # %% ../nbs/00_core.ipynb #258e3058
191
+ class AXNode:
192
+ "Chrome accessibility tree node with compact repr"
193
+ def __init__(self, raw):
194
+ self.role = nested_idx(raw, 'role', 'value') or ''
195
+ self.name = nested_idx(raw, 'name', 'value') or ''
196
+ self.props = {p['name']: nested_idx(p, 'value', 'value') for p in raw.get('properties', [])}
197
+ self.children = []
198
+ self.node_id = raw.get('nodeId')
199
+ self.backend_id = raw.get('backendDOMNodeId')
200
+ self.ignored = raw.get('ignored', False)
201
+ self._raw = raw
202
+
203
+ def _repr_markdown_(self, depth=0):
204
+ pre = ' ' * depth
205
+ ps = ''.join(f' `{k}={v}`' for k,v in self.props.items() if v not in (False, None, '', 'false'))
206
+ bid = f' [#{self.backend_id}]' if self.backend_id else ''
207
+ line = f'{pre}- **{self.role}** "{self.name}"{ps}{bid}'
208
+ return '\n'.join([line] + [c._repr_markdown_(depth+1) for c in self.children])
209
+
210
+ __str__ = __repr__ = _repr_markdown_
211
+
212
+ class AXTree(AXNode):
213
+ def _repr_markdown_(self): return f'<div class="prose">\n\n{super()._repr_markdown_(0)}\n\n</div>'
214
+
215
+ # %% ../nbs/00_core.ipynb #492259b0
216
+ def _simplify(node):
217
+ node.children = [n for c in node.children for n in _simplify(c)]
218
+ if node.role in ('none', 'generic', 'paragraph') and not node.name:
219
+ if not node.children: return []
220
+ return node.children # splice children up
221
+ return [node]
222
+
223
+ def build_ax_tree(nodes):
224
+ "Build AXNode tree from flat CDP accessibility node list"
225
+ by_id = {}
226
+ for raw in nodes:
227
+ n = AXNode(raw)
228
+ by_id[n.node_id] = n
229
+ for raw in nodes:
230
+ parent = by_id.get(raw.get('nodeId'))
231
+ for cid in raw.get('childIds', []):
232
+ if (child := by_id.get(cid)): parent.children.append(child)
233
+ if not nodes: return
234
+ root = by_id.get(nodes[0]['nodeId'])
235
+ if not (result := _simplify(root)): return None
236
+ result = result[0]
237
+ result.__class__ = AXTree
238
+ return result
239
+
240
+ # %% ../nbs/00_core.ipynb #d3e7a635
241
+ @patch
242
+ async def ax_tree(self:JsCDP, sid=None):
243
+ "Get accessibility tree for session"
244
+ await self.accessibility.enable(sid=sid)
245
+ return build_ax_tree(await self.accessibility.getFullAXTree(sid=sid))
246
+
247
+ # %% ../nbs/00_core.ipynb #d317c80b
248
+ @patch
249
+ def find(self:AXNode, role=None, name=None):
250
+ "Find first descendant matching role and/or name substring"
251
+ if (not role or role == self.role) and (not name or name in self.name): return self
252
+ for c in self.children:
253
+ if (r := c.find(role, name)): return r
254
+
255
+ @patch
256
+ def find_id(self:AXNode, role=None, name=None):
257
+ "Find first descendant matching role and/or name substring"
258
+ res = self.find(role, name)
259
+ return res.backend_id if res else None
260
+
261
+ @patch
262
+ def find_all(self:AXNode, role=None, name=None):
263
+ "Find all descendants matching role and/or name substring"
264
+ res = []
265
+ if (not role or role == self.role) and (not name or name in self.name): res.append(self)
266
+ for c in self.children: res += c.find_all(role, name)
267
+ return res
268
+
269
+ # %% ../nbs/00_core.ipynb #28b872d9
270
+ @patch
271
+ async def js_node(self:JsCDP, fn, backendNodeId, sid=None):
272
+ obj = await self.DOM.resolveNode(sid=sid, backendNodeId=backendNodeId)
273
+ return await self.runtime.callFunctionOn(sid=sid, functionDeclaration=fn,
274
+ objectId=obj['objectId'])
275
+
276
+ @patch
277
+ async def js_node_run(self:JsCDP, code, backendNodeId, sid=None):
278
+ return await self.js_node(f'function() {{ {code} }}', backendNodeId, sid=sid)
279
+
280
+ @patch
281
+ async def click(self:JsCDP, backendNodeId, sid=None):
282
+ await self.js_node_run('this.click()', backendNodeId, sid=sid)
283
+
284
+ # %% ../nbs/00_core.ipynb #3e826905
285
+ @patch
286
+ async def fill_text(self:JsCDP, backendNodeId, text, sid=None):
287
+ await self.DOM.focus(sid=sid, backendNodeId=backendNodeId)
288
+ await self.input.insertText(sid=sid, text=text)
289
+
290
+ # %% ../nbs/00_core.ipynb #3eec012b
291
+ @patch
292
+ async def click_and_wait(self:JsCDP, backendNodeId, **kwargs):
293
+ "Click element and wait for load+idle"
294
+ await self.js_node_run('this.click()', backendNodeId)
295
+ await self.wait_for_ready(**kwargs)
296
+
297
+ # %% ../nbs/00_core.ipynb #605c1669
298
+ def cdp_yolo():
299
+ "Allow all CDP classes in safepyrun"
300
+ from safepyrun import allow
301
+ allow({JsCDP:..., JsCDPDomain:..., JsCDPMethod:...})
302
+ allow({AXNode:..., AXTree:...})