repld-tool 0.0.1__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.
- repld/__init__.py +3 -0
- repld/bridge.py +70 -0
- repld/browser/__init__.py +428 -0
- repld/browser/capture.py +206 -0
- repld/browser/cdp.py +269 -0
- repld/browser/har.py +558 -0
- repld/browser/observe.py +624 -0
- repld/browser/session.py +511 -0
- repld/browser/tab.py +1162 -0
- repld/cli.py +60 -0
- repld/display.py +408 -0
- repld/events.py +170 -0
- repld/exec_cmd.py +336 -0
- repld/gates.py +100 -0
- repld/gists.py +356 -0
- repld/help.py +529 -0
- repld/ipc.py +260 -0
- repld/kernel.py +689 -0
- repld/protocol.py +906 -0
- repld/runtime.py +131 -0
- repld/scaffold.py +193 -0
- repld/tasks.py +258 -0
- repld_tool-0.0.1.dist-info/METADATA +221 -0
- repld_tool-0.0.1.dist-info/RECORD +27 -0
- repld_tool-0.0.1.dist-info/WHEEL +4 -0
- repld_tool-0.0.1.dist-info/entry_points.txt +3 -0
- repld_tool-0.0.1.dist-info/licenses/LICENSE +21 -0
repld/__init__.py
ADDED
repld/bridge.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Stdio MCP ↔ unix-socket bridge.
|
|
2
|
+
|
|
3
|
+
Dumb bidirectional byte-pipe. Does not parse MCP. Reads the kernel's socket
|
|
4
|
+
path from ./.pyrepl.lock, connects, then:
|
|
5
|
+
|
|
6
|
+
stdin → socket (thread 1)
|
|
7
|
+
socket → stdout (thread 2)
|
|
8
|
+
|
|
9
|
+
Exits on EOF from either side. One bridge = one MCP client session.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import socket
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .ipc import connect_to_kernel
|
|
18
|
+
|
|
19
|
+
LOCK_PATH = Path.cwd() / ".pyrepl.lock"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _err(msg: str) -> None:
|
|
23
|
+
print(f"repld bridge: {msg}", file=sys.stderr, flush=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_bridge(argv: list[str]) -> int:
|
|
27
|
+
result = connect_to_kernel(LOCK_PATH)
|
|
28
|
+
if isinstance(result, str):
|
|
29
|
+
_err(result)
|
|
30
|
+
return 1
|
|
31
|
+
sock, _lock = result
|
|
32
|
+
|
|
33
|
+
stop = threading.Event()
|
|
34
|
+
|
|
35
|
+
def stdin_to_sock() -> None:
|
|
36
|
+
try:
|
|
37
|
+
for line in sys.stdin:
|
|
38
|
+
if not line.endswith("\n"):
|
|
39
|
+
line = line + "\n"
|
|
40
|
+
sock.sendall(line.encode("utf-8"))
|
|
41
|
+
except (BrokenPipeError, OSError):
|
|
42
|
+
stop.set()
|
|
43
|
+
finally:
|
|
44
|
+
# Half-close write side so the kernel sees EOF and drains/closes.
|
|
45
|
+
# DO NOT set stop here: in-flight responses may still be inbound.
|
|
46
|
+
try:
|
|
47
|
+
sock.shutdown(socket.SHUT_WR)
|
|
48
|
+
except OSError:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
def sock_to_stdout() -> None:
|
|
52
|
+
try:
|
|
53
|
+
rfile = sock.makefile("r", encoding="utf-8")
|
|
54
|
+
for line in rfile:
|
|
55
|
+
sys.stdout.write(line)
|
|
56
|
+
sys.stdout.flush()
|
|
57
|
+
except (BrokenPipeError, OSError):
|
|
58
|
+
pass
|
|
59
|
+
finally:
|
|
60
|
+
# Socket-side EOF drives shutdown.
|
|
61
|
+
stop.set()
|
|
62
|
+
|
|
63
|
+
threading.Thread(target=stdin_to_sock, daemon=True, name="bridge-stdin").start()
|
|
64
|
+
threading.Thread(target=sock_to_stdout, daemon=True, name="bridge-stdout").start()
|
|
65
|
+
stop.wait()
|
|
66
|
+
try:
|
|
67
|
+
sock.close()
|
|
68
|
+
except OSError:
|
|
69
|
+
pass
|
|
70
|
+
return 0
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""repld.browser — CDP integration for repld.
|
|
2
|
+
|
|
3
|
+
PUBLIC API:
|
|
4
|
+
- LazyBrowser: Descriptor injected into __main__; lazy-bootstraps on first access.
|
|
5
|
+
- Browser: Manages BrowserSession, watch patterns, and Tab resolution.
|
|
6
|
+
|
|
7
|
+
Usage in kernel:
|
|
8
|
+
setattr(__main__, "browser", LazyBrowser(loop))
|
|
9
|
+
|
|
10
|
+
Then in user code:
|
|
11
|
+
tab = await browser.get("*github.com*") # find one tab by glob
|
|
12
|
+
tab = await browser.get("9222:887d3d") # find one tab by target ID
|
|
13
|
+
await browser.watch("*github.com*") # watch all matching
|
|
14
|
+
await tab.js("document.title")
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import fnmatch
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from ..events import BrowserTabAttached, BrowserTabDetached, emit
|
|
25
|
+
from .session import WORKER_TYPES
|
|
26
|
+
from .tab import Rows, Tab
|
|
27
|
+
|
|
28
|
+
__all__ = ["Browser", "LazyBrowser"]
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_TARGET_ID_RE = re.compile(r"^\d+:[0-9a-f]{6}$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_target_id(s: str) -> bool:
|
|
37
|
+
"""True if s looks like a short target ID (e.g. '9222:a81998')."""
|
|
38
|
+
return bool(_TARGET_ID_RE.match(s))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def make_target(port: int, chrome_id: str) -> str:
|
|
42
|
+
"""Create short target ID from port and Chrome target ID.
|
|
43
|
+
|
|
44
|
+
Format: "{port}:{6-char-lowercase-hex}"
|
|
45
|
+
Example: make_target(9222, "887D3D7FA9473DCF...") -> "9222:887d3d"
|
|
46
|
+
"""
|
|
47
|
+
return f"{port}:{chrome_id[:6].lower()}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Browser:
|
|
51
|
+
"""Manages the BrowserSession + watch patterns + Tab resolution.
|
|
52
|
+
|
|
53
|
+
Injected into __main__ by the kernel after lazy initialization.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self, loop: asyncio.AbstractEventLoop, port: int | None = None
|
|
58
|
+
) -> None:
|
|
59
|
+
from .session import BrowserSession
|
|
60
|
+
|
|
61
|
+
self.port = port or int(os.environ.get("REPLD_CHROME_PORT", "9222"))
|
|
62
|
+
self._session: BrowserSession = BrowserSession(self.port)
|
|
63
|
+
self._loop: asyncio.AbstractEventLoop = loop
|
|
64
|
+
self._connected: bool = False
|
|
65
|
+
|
|
66
|
+
async def _ensure_connected(self) -> None:
|
|
67
|
+
if not self._connected:
|
|
68
|
+
await self._session.connect()
|
|
69
|
+
self._session._on_target_created = self._on_target_created
|
|
70
|
+
self._session._on_target_destroyed = self._on_target_destroyed
|
|
71
|
+
self._connected = True
|
|
72
|
+
logger.debug("BrowserSession connected on port %s", self.port)
|
|
73
|
+
elif not self._session._is_connected():
|
|
74
|
+
await self._session._reconnect()
|
|
75
|
+
|
|
76
|
+
def _on_target_created(self, target_info: dict, target_id: str) -> None:
|
|
77
|
+
"""Called when a new tab is auto-attached."""
|
|
78
|
+
url = target_info.get("url", "")
|
|
79
|
+
title = target_info.get("title", "")
|
|
80
|
+
emit(BrowserTabAttached(target_id, url, title))
|
|
81
|
+
|
|
82
|
+
def _on_target_destroyed(self, target_id: str) -> None:
|
|
83
|
+
"""Called when a tab is destroyed."""
|
|
84
|
+
emit(BrowserTabDetached(target_id))
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
# Public API
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
async def get(
|
|
91
|
+
self,
|
|
92
|
+
target: str,
|
|
93
|
+
*,
|
|
94
|
+
timeout: float | None = None,
|
|
95
|
+
fresh: bool = False,
|
|
96
|
+
) -> Tab:
|
|
97
|
+
"""Find one tab by URL glob or target ID. Attach on demand.
|
|
98
|
+
|
|
99
|
+
**Glob** (e.g. ``"*github.com*"``): searches pages and iframes,
|
|
100
|
+
skips workers. ``timeout`` polls until a match appears. ``fresh``
|
|
101
|
+
skips tabs that already matched at call time.
|
|
102
|
+
|
|
103
|
+
**Target ID** (e.g. ``"9222:a81998"``): resolves any type including
|
|
104
|
+
workers. Attaches if not already attached. ``timeout``/``fresh``
|
|
105
|
+
are ignored.
|
|
106
|
+
"""
|
|
107
|
+
if _is_target_id(target):
|
|
108
|
+
return await self._get_by_id(target)
|
|
109
|
+
return await self._get_by_glob(target, timeout=timeout, fresh=fresh)
|
|
110
|
+
|
|
111
|
+
async def _get_by_id(self, target: str) -> Tab:
|
|
112
|
+
"""Resolve a target ID, attaching on demand if needed."""
|
|
113
|
+
# Fast path: already attached
|
|
114
|
+
_, prefix = target.split(":", 1)
|
|
115
|
+
for cdp in self._session._sessions.values():
|
|
116
|
+
chrome_id = cdp.target_info.get("targetId", "")
|
|
117
|
+
if chrome_id[:6].lower() == prefix:
|
|
118
|
+
return Tab(cdp, chrome_id, self.port)
|
|
119
|
+
|
|
120
|
+
# Slow path: find in all targets and attach
|
|
121
|
+
await self._ensure_connected()
|
|
122
|
+
for t in await self._session.list_targets():
|
|
123
|
+
tid = t.get("targetId", "")
|
|
124
|
+
if tid and tid[:6].lower() == prefix:
|
|
125
|
+
cdp = await self._session.attach(tid)
|
|
126
|
+
if cdp is not None:
|
|
127
|
+
return Tab(cdp, tid, self.port)
|
|
128
|
+
|
|
129
|
+
attached = [
|
|
130
|
+
make_target(self.port, cdp.target_info.get("targetId", ""))
|
|
131
|
+
for cdp in self._session._sessions.values()
|
|
132
|
+
]
|
|
133
|
+
raise RuntimeError(f"No tab '{target}'. Attached: {attached}")
|
|
134
|
+
|
|
135
|
+
async def _get_by_glob(
|
|
136
|
+
self,
|
|
137
|
+
pattern: str,
|
|
138
|
+
*,
|
|
139
|
+
timeout: float | None = None,
|
|
140
|
+
fresh: bool = False,
|
|
141
|
+
) -> Tab:
|
|
142
|
+
"""Find one tab matching a URL glob. Skips workers."""
|
|
143
|
+
# Snapshot existing matches so fresh=True can exclude them.
|
|
144
|
+
exclude: set[str] = set()
|
|
145
|
+
if fresh:
|
|
146
|
+
for cdp in self._session._sessions.values():
|
|
147
|
+
url = cdp.target_info.get("url", "")
|
|
148
|
+
if fnmatch.fnmatch(url, pattern):
|
|
149
|
+
exclude.add(cdp.target_info.get("targetId", ""))
|
|
150
|
+
await self._ensure_connected()
|
|
151
|
+
for t in await self._session.list_targets():
|
|
152
|
+
url = t.get("url", "")
|
|
153
|
+
tid = t.get("targetId", "")
|
|
154
|
+
if fnmatch.fnmatch(url, pattern) and tid:
|
|
155
|
+
exclude.add(tid)
|
|
156
|
+
|
|
157
|
+
deadline = (
|
|
158
|
+
asyncio.get_running_loop().time() + timeout if timeout is not None else None
|
|
159
|
+
)
|
|
160
|
+
while True:
|
|
161
|
+
# 1. Check already-attached tabs (skip workers)
|
|
162
|
+
for cdp in self._session._sessions.values():
|
|
163
|
+
if cdp.target_info.get("type", "") in WORKER_TYPES:
|
|
164
|
+
continue
|
|
165
|
+
url = cdp.target_info.get("url", "")
|
|
166
|
+
tid = cdp.target_info.get("targetId", "")
|
|
167
|
+
if fnmatch.fnmatch(url, pattern) and tid not in exclude:
|
|
168
|
+
return Tab(cdp, tid, self.port)
|
|
169
|
+
|
|
170
|
+
# 2. Search all Chrome targets, attach first match (skip workers)
|
|
171
|
+
await self._ensure_connected()
|
|
172
|
+
for t in await self._session.list_targets():
|
|
173
|
+
if t.get("type", "") in WORKER_TYPES:
|
|
174
|
+
continue
|
|
175
|
+
url = t.get("url", "")
|
|
176
|
+
tid = t.get("targetId", "")
|
|
177
|
+
if fnmatch.fnmatch(url, pattern) and tid and tid not in exclude:
|
|
178
|
+
cdp = await self._session.attach(tid)
|
|
179
|
+
if cdp is not None:
|
|
180
|
+
return Tab(cdp, tid, self.port)
|
|
181
|
+
|
|
182
|
+
if deadline is None or asyncio.get_running_loop().time() >= deadline:
|
|
183
|
+
break
|
|
184
|
+
await asyncio.sleep(0.3)
|
|
185
|
+
|
|
186
|
+
raise RuntimeError(f"No tab matching '{pattern}'")
|
|
187
|
+
|
|
188
|
+
def _resolve_attached(self, target: str) -> Tab:
|
|
189
|
+
"""Sync lookup of an already-attached tab by short target ID.
|
|
190
|
+
|
|
191
|
+
Used by clear() and open() where the tab is guaranteed attached.
|
|
192
|
+
"""
|
|
193
|
+
if ":" not in target:
|
|
194
|
+
raise RuntimeError(
|
|
195
|
+
f"Invalid target ID '{target}'. Expected format: '9222:a1b2c3'"
|
|
196
|
+
)
|
|
197
|
+
_, prefix = target.split(":", 1)
|
|
198
|
+
for cdp in self._session._sessions.values():
|
|
199
|
+
chrome_id = cdp.target_info.get("targetId", "")
|
|
200
|
+
if chrome_id[:6].lower() == prefix:
|
|
201
|
+
return Tab(cdp, chrome_id, self.port)
|
|
202
|
+
|
|
203
|
+
attached = [
|
|
204
|
+
make_target(self.port, cdp.target_info.get("targetId", ""))
|
|
205
|
+
for cdp in self._session._sessions.values()
|
|
206
|
+
]
|
|
207
|
+
raise RuntimeError(f"No attached tab '{target}'. Attached: {attached}")
|
|
208
|
+
|
|
209
|
+
async def watch(self, pattern: str) -> str:
|
|
210
|
+
"""Register a URL glob pattern and attach currently-matching tabs.
|
|
211
|
+
|
|
212
|
+
Future tabs matching the pattern auto-attach. Workers are skipped.
|
|
213
|
+
Returns a summary string.
|
|
214
|
+
"""
|
|
215
|
+
await self._ensure_connected()
|
|
216
|
+
|
|
217
|
+
# Add pattern (registers it in _watched_patterns)
|
|
218
|
+
self._session.add_pattern(pattern)
|
|
219
|
+
|
|
220
|
+
# Attach any targets that match the pattern and aren't already attached
|
|
221
|
+
newly_attached: list[str] = []
|
|
222
|
+
targets = await self._session.list_targets()
|
|
223
|
+
for t in targets:
|
|
224
|
+
if t.get("type", "") in WORKER_TYPES:
|
|
225
|
+
continue
|
|
226
|
+
tid = t.get("targetId", "")
|
|
227
|
+
url = t.get("url", "")
|
|
228
|
+
if fnmatch.fnmatch(url, pattern) and tid:
|
|
229
|
+
# Check if already attached
|
|
230
|
+
already = any(
|
|
231
|
+
cdp.target_info.get("targetId") == tid
|
|
232
|
+
for cdp in self._session._sessions.values()
|
|
233
|
+
)
|
|
234
|
+
if not already:
|
|
235
|
+
try:
|
|
236
|
+
await self._session.attach(tid)
|
|
237
|
+
newly_attached.append(tid)
|
|
238
|
+
self._session._watched_patterns.setdefault(pattern, set()).add(
|
|
239
|
+
tid
|
|
240
|
+
)
|
|
241
|
+
except Exception as exc:
|
|
242
|
+
logger.debug("Attach %s: %s", tid, exc)
|
|
243
|
+
|
|
244
|
+
total = len(self._session._sessions)
|
|
245
|
+
return (
|
|
246
|
+
f"Attached {len(newly_attached)} new tab(s) for pattern '{pattern}'. "
|
|
247
|
+
f"Total attached: {total}."
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
async def open(self, url: str) -> "Tab":
|
|
251
|
+
"""Create a new tab and attach to it.
|
|
252
|
+
|
|
253
|
+
Target.createTarget → attach → return Tab.
|
|
254
|
+
"""
|
|
255
|
+
await self._ensure_connected()
|
|
256
|
+
result = await self._session.execute("Target.createTarget", {"url": url})
|
|
257
|
+
tid = result["targetId"]
|
|
258
|
+
await self._session.attach(tid)
|
|
259
|
+
return self._resolve_attached(make_target(self.port, tid))
|
|
260
|
+
|
|
261
|
+
async def detach(self, pattern: str | None = None) -> str:
|
|
262
|
+
"""Detach tabs by pattern; detach all if pattern is None."""
|
|
263
|
+
if not self._connected:
|
|
264
|
+
return "No browser connection."
|
|
265
|
+
|
|
266
|
+
if pattern is None:
|
|
267
|
+
# Detach everything
|
|
268
|
+
session_ids = list(self._session._sessions.keys())
|
|
269
|
+
for sid in session_ids:
|
|
270
|
+
try:
|
|
271
|
+
await self._session.detach(sid)
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
logger.debug("Detach %s: %s", sid, exc)
|
|
274
|
+
self._session._watched_patterns.clear()
|
|
275
|
+
return f"Detached {len(session_ids)} tab(s). All patterns cleared."
|
|
276
|
+
|
|
277
|
+
# Detach sessions matching this pattern
|
|
278
|
+
to_detach: list[str] = []
|
|
279
|
+
for sid, cdp in list(self._session._sessions.items()):
|
|
280
|
+
url = cdp.target_info.get("url", "")
|
|
281
|
+
if fnmatch.fnmatch(url, pattern):
|
|
282
|
+
to_detach.append(sid)
|
|
283
|
+
|
|
284
|
+
for sid in to_detach:
|
|
285
|
+
try:
|
|
286
|
+
await self._session.detach(sid)
|
|
287
|
+
except Exception as exc:
|
|
288
|
+
logger.debug("Detach %s: %s", sid, exc)
|
|
289
|
+
|
|
290
|
+
# Remove pattern
|
|
291
|
+
self._session._watched_patterns.pop(pattern, None)
|
|
292
|
+
return f"Detached {len(to_detach)} tab(s) for pattern '{pattern}'."
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def tabs(self) -> Rows:
|
|
296
|
+
"""List currently attached Tab objects."""
|
|
297
|
+
return Rows(
|
|
298
|
+
Tab(cdp, cdp.target_info.get("targetId", ""), self.port)
|
|
299
|
+
for cdp in self._session._sessions.values()
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
async def pages(self) -> list[dict]:
|
|
303
|
+
"""List all Chrome targets (attached or not)."""
|
|
304
|
+
await self._ensure_connected()
|
|
305
|
+
return await self._session.list_targets()
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def patterns(self) -> list[str]:
|
|
309
|
+
"""List active watch patterns."""
|
|
310
|
+
return list(self._session._watched_patterns.keys())
|
|
311
|
+
|
|
312
|
+
def clear(self, target: str | None = None) -> str:
|
|
313
|
+
"""Clear captured events. Specify target for one tab, or None for all."""
|
|
314
|
+
if target is not None:
|
|
315
|
+
tab = self._resolve_attached(target)
|
|
316
|
+
tab.clear()
|
|
317
|
+
return f"Cleared events for {target}."
|
|
318
|
+
count = 0
|
|
319
|
+
for cdp in self._session._sessions.values():
|
|
320
|
+
cdp.clear_events()
|
|
321
|
+
count += 1
|
|
322
|
+
return f"Cleared events for {count} tab(s)."
|
|
323
|
+
|
|
324
|
+
async def disconnect(self) -> None:
|
|
325
|
+
"""Disconnect from Chrome."""
|
|
326
|
+
if self._connected:
|
|
327
|
+
try:
|
|
328
|
+
await self._session.disconnect()
|
|
329
|
+
except Exception:
|
|
330
|
+
pass
|
|
331
|
+
self._connected = False
|
|
332
|
+
|
|
333
|
+
def format_tabs_nested(self) -> str:
|
|
334
|
+
"""Format attached tabs as nested text showing target hierarchy."""
|
|
335
|
+
entries: list[dict] = []
|
|
336
|
+
for cdp in self._session._sessions.values():
|
|
337
|
+
info = cdp.target_info
|
|
338
|
+
entries.append(
|
|
339
|
+
{
|
|
340
|
+
"target": make_target(self.port, info.get("targetId", "")),
|
|
341
|
+
"type": info.get("type", "unknown"),
|
|
342
|
+
"url": info.get("url", ""),
|
|
343
|
+
"title": info.get("title", ""),
|
|
344
|
+
"parent_frame_id": info.get("parentFrameId", ""),
|
|
345
|
+
"opener_id": info.get("openerId", ""),
|
|
346
|
+
}
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Build parent lookup: full chrome ID → short target ID
|
|
350
|
+
id_to_short: dict[str, str] = {}
|
|
351
|
+
for cdp in self._session._sessions.values():
|
|
352
|
+
info = cdp.target_info
|
|
353
|
+
full_id = info.get("targetId", "")
|
|
354
|
+
id_to_short[full_id] = make_target(self.port, full_id)
|
|
355
|
+
|
|
356
|
+
# Separate top-level vs children
|
|
357
|
+
children: dict[str, list[dict]] = {}
|
|
358
|
+
top_level: list[dict] = []
|
|
359
|
+
|
|
360
|
+
for e in entries:
|
|
361
|
+
parent_id = e["parent_frame_id"] or e["opener_id"]
|
|
362
|
+
parent_short = id_to_short.get(parent_id)
|
|
363
|
+
if parent_short:
|
|
364
|
+
children.setdefault(parent_short, []).append(e)
|
|
365
|
+
else:
|
|
366
|
+
top_level.append(e)
|
|
367
|
+
|
|
368
|
+
# Format output
|
|
369
|
+
lines: list[str] = []
|
|
370
|
+
for e in top_level:
|
|
371
|
+
lines.append(f"{e['target']} {e['type']} {e['url']}")
|
|
372
|
+
for child in children.get(e["target"], []):
|
|
373
|
+
lines.append(f" {child['target']} {child['type']} {child['url']}")
|
|
374
|
+
|
|
375
|
+
# Orphaned children (parent not attached)
|
|
376
|
+
shown = {e["target"] for e in top_level}
|
|
377
|
+
for parent_short, kids in children.items():
|
|
378
|
+
if parent_short not in shown:
|
|
379
|
+
for child in kids:
|
|
380
|
+
lines.append(
|
|
381
|
+
f"{child['target']} {child['type']} → {parent_short} {child['url']}"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
return "\n".join(lines) if lines else "(no attached tabs)"
|
|
385
|
+
|
|
386
|
+
def help(self) -> None:
|
|
387
|
+
"""Print the Python API reference for the browser object."""
|
|
388
|
+
from ..help import _TOPICS
|
|
389
|
+
|
|
390
|
+
print(_TOPICS["browser"])
|
|
391
|
+
|
|
392
|
+
def __repr__(self) -> str:
|
|
393
|
+
n = len(self._session._sessions) if self._connected else 0
|
|
394
|
+
return f"<Browser port={self.port} tabs={n} patterns={self.patterns}>"
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class LazyBrowser:
|
|
398
|
+
"""Lazy descriptor injected into __main__.
|
|
399
|
+
|
|
400
|
+
On first attribute access, bootstraps the real Browser object and
|
|
401
|
+
replaces itself in __main__.__dict__.
|
|
402
|
+
"""
|
|
403
|
+
|
|
404
|
+
def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
405
|
+
self._loop = loop
|
|
406
|
+
self._real: Browser | None = None
|
|
407
|
+
|
|
408
|
+
def _bootstrap(self) -> Browser:
|
|
409
|
+
if self._real is None:
|
|
410
|
+
self._real = Browser(self._loop)
|
|
411
|
+
return self._real
|
|
412
|
+
|
|
413
|
+
def help(self) -> None:
|
|
414
|
+
"""Print the Python API reference (no Chrome connection needed)."""
|
|
415
|
+
from ..help import _TOPICS
|
|
416
|
+
|
|
417
|
+
print(_TOPICS["browser"])
|
|
418
|
+
|
|
419
|
+
def __getattr__(self, name: str) -> Any:
|
|
420
|
+
return getattr(self._bootstrap(), name)
|
|
421
|
+
|
|
422
|
+
def __repr__(self) -> str:
|
|
423
|
+
if self._real is not None:
|
|
424
|
+
return repr(self._real)
|
|
425
|
+
return "<Browser (lazy — call browser.watch() to connect)>"
|
|
426
|
+
|
|
427
|
+
def __reduce__(self): # type: ignore[override]
|
|
428
|
+
raise TypeError("LazyBrowser is not serializable")
|