webtap-tool 0.4.0__py3-none-any.whl → 0.5.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.

Potentially problematic release.


This version of webtap-tool might be problematic. Click here for more details.

@@ -0,0 +1,129 @@
1
+ """Browser element selection and prompt analysis commands.
2
+
3
+ PUBLIC API:
4
+ - browser: Analyze browser element selections with prompt
5
+ """
6
+
7
+ from webtap.app import app
8
+ from webtap.commands._utils import evaluate_expression, format_expression_result
9
+ from webtap.commands._builders import error_response
10
+ from webtap.commands._tips import get_tips
11
+
12
+
13
+ @app.command(
14
+ display="markdown",
15
+ fastmcp=[{"type": "resource", "mime_type": "application/json"}, {"type": "tool"}],
16
+ )
17
+ def selections(state, expr: str = None) -> dict: # pyright: ignore[reportArgumentType]
18
+ """Browser element selections with prompt and analysis.
19
+
20
+ As Resource (no parameters):
21
+ browser # Returns current prompt and all selections
22
+
23
+ As Tool (with parameters):
24
+ browser(expr="data['prompt']") # Get prompt text
25
+ browser(expr="data['selections']['1']['styles']") # Get styles for #1
26
+ browser(expr="len(data['selections'])") # Count selections
27
+ browser(expr="{k: v['selector'] for k, v in data['selections'].items()}") # All selectors
28
+
29
+ Args:
30
+ expr: Python expression with 'data' variable containing prompt and selections
31
+
32
+ Returns:
33
+ Formatted browser data or expression result
34
+ """
35
+ # Check if browser data exists
36
+ if not hasattr(state, "browser_data") or not state.browser_data:
37
+ return error_response(
38
+ "No browser selections available",
39
+ suggestions=[
40
+ "Use the Chrome extension to select elements",
41
+ "Click 'Start Selection Mode' in the extension popup",
42
+ "Select elements on the page and submit a prompt",
43
+ ],
44
+ )
45
+
46
+ data = state.browser_data
47
+
48
+ # No expression - RESOURCE MODE: Return formatted view
49
+ if not expr:
50
+ return _format_browser_data(data)
51
+
52
+ # TOOL MODE: Evaluate expression
53
+ try:
54
+ namespace = {"data": data}
55
+ result, output = evaluate_expression(expr, namespace)
56
+ formatted_result = format_expression_result(result, output)
57
+
58
+ # Build markdown response
59
+ return {
60
+ "elements": [
61
+ {"type": "heading", "content": "Expression Result", "level": 2},
62
+ {"type": "code_block", "content": expr, "language": "python"},
63
+ {"type": "text", "content": "**Result:**"},
64
+ {"type": "code_block", "content": formatted_result, "language": ""},
65
+ ]
66
+ }
67
+ except Exception as e:
68
+ # Provide helpful suggestions
69
+ suggestions = [
70
+ "The data is available as 'data' variable",
71
+ "Access prompt: data['prompt']",
72
+ "Access selections: data['selections']",
73
+ "Access specific element: data['selections']['1']",
74
+ "Available fields: outerHTML, selector, jsPath, styles, xpath, fullXpath, preview",
75
+ ]
76
+
77
+ if "KeyError" in str(type(e).__name__):
78
+ suggestions.extend(
79
+ [
80
+ "Check available selection IDs: list(data['selections'].keys())",
81
+ "Check available fields: data['selections']['1'].keys()",
82
+ ]
83
+ )
84
+
85
+ return error_response(f"{type(e).__name__}: {e}", suggestions=suggestions)
86
+
87
+
88
+ def _format_browser_data(data: dict) -> dict:
89
+ """Format browser data as markdown for resource view."""
90
+ elements = []
91
+
92
+ # Show prompt
93
+ elements.append({"type": "heading", "content": "Browser Prompt", "level": 2})
94
+ elements.append({"type": "text", "content": data.get("prompt", "")})
95
+
96
+ # Show selection count
97
+ selection_count = len(data.get("selections", {}))
98
+ elements.append({"type": "text", "content": f"\n**Selected Elements:** {selection_count}"})
99
+
100
+ # Show each selection with preview
101
+ if selection_count > 0:
102
+ elements.append({"type": "heading", "content": "Element Selections", "level": 3})
103
+
104
+ for sel_id in sorted(data["selections"].keys(), key=lambda x: int(x)):
105
+ sel = data["selections"][sel_id]
106
+ preview = sel.get("preview", {})
107
+
108
+ # Build preview line
109
+ preview_parts = [f"**#{sel_id}:**", preview.get("tag", "unknown")]
110
+ if preview.get("id"):
111
+ preview_parts.append(f"#{preview['id']}")
112
+ if preview.get("classes"):
113
+ preview_parts.append(f".{preview['classes'][0]}")
114
+
115
+ elements.append({"type": "text", "content": " ".join(preview_parts)})
116
+
117
+ # Show selector
118
+ elements.append({"type": "code_block", "content": sel.get("selector", ""), "language": "css"})
119
+
120
+ # Show usage tips from TIPS.md
121
+ tips = get_tips("selections")
122
+ if tips:
123
+ elements.append({"type": "heading", "content": "Next Steps", "level": 3})
124
+ elements.append({"type": "list", "items": tips})
125
+
126
+ return {"elements": elements}
127
+
128
+
129
+ __all__ = ["selections"]
webtap/commands/server.py CHANGED
@@ -20,6 +20,7 @@ API_PORT = 8765
20
20
  def _check_port() -> bool:
21
21
  """Check if API port is in use."""
22
22
  with socket.socket() as s:
23
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
23
24
  try:
24
25
  s.bind(("127.0.0.1", API_PORT))
25
26
  return False # Port is free
webtap/services/dom.py ADDED
@@ -0,0 +1,512 @@
1
+ """DOM inspection service using Chrome DevTools Protocol.
2
+
3
+ PUBLIC API:
4
+ - DOMService: Manages element inspection and selection via CDP Overlay domain
5
+ """
6
+
7
+ import logging
8
+ import re
9
+ import threading
10
+ from concurrent.futures import ThreadPoolExecutor
11
+ from typing import Any, TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from webtap.cdp.session import CDPSession
15
+ from webtap.app import WebTapState
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class DOMService:
21
+ """Manages element inspection and selection via CDP Overlay domain.
22
+
23
+ Uses CDP's native inspect mode (Overlay.setInspectMode) which provides:
24
+ - Native Chrome highlight on hover (no custom overlay needed)
25
+ - Click events via Overlay.inspectNodeRequested
26
+ - Accurate element data via DOM.describeNode, CSS.getComputedStyleForNode
27
+
28
+ Selections are stored in state.browser_data (not DuckDB) as they are
29
+ ephemeral session data cleared after prompt submission.
30
+
31
+ Attributes:
32
+ cdp: CDP session for executing commands
33
+ state: WebTap state for storing selections
34
+ _inspection_active: Whether inspect mode is currently active
35
+ _next_id: Counter for assigning selection IDs
36
+ """
37
+
38
+ def __init__(self, cdp: "CDPSession | None" = None, state: "WebTapState | None" = None):
39
+ """Initialize DOM service.
40
+
41
+ Args:
42
+ cdp: CDPSession instance. Can be None initially, set via set_cdp().
43
+ state: WebTapState instance. Can be None initially, set via set_state().
44
+ """
45
+ self.cdp = cdp
46
+ self.state = state
47
+ self._inspection_active = False
48
+ self._next_id = 1
49
+ self._broadcast_queue: "Any | None" = None # asyncio.Queue for thread-safe broadcasts
50
+ self._state_lock = threading.Lock() # Protect state mutations
51
+ self._pending_selections = 0 # Track in-flight selection processing
52
+ self._executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="dom-worker")
53
+
54
+ def set_cdp(self, cdp: "CDPSession") -> None:
55
+ """Set CDP session after initialization."""
56
+ self.cdp = cdp
57
+
58
+ def set_state(self, state: "WebTapState") -> None:
59
+ """Set state after initialization."""
60
+ self.state = state
61
+
62
+ def set_broadcast_queue(self, queue: "Any") -> None:
63
+ """Set queue for broadcasting state changes.
64
+
65
+ Args:
66
+ queue: asyncio.Queue for thread-safe signaling
67
+ """
68
+ self._broadcast_queue = queue
69
+
70
+ def start_inspect(self) -> dict[str, Any]:
71
+ """Enable CDP element inspection mode.
72
+
73
+ Enables Overlay.setInspectMode with searchForNode mode, which:
74
+ - Shows native Chrome highlight on hover
75
+ - Fires Overlay.inspectNodeRequested on click
76
+
77
+ Returns:
78
+ Success status dictionary.
79
+ """
80
+ if not self.cdp or not self.cdp.ws_app:
81
+ return {"error": "Not connected to page"}
82
+
83
+ if self._inspection_active:
84
+ return {"error": "Inspection already active"}
85
+
86
+ try:
87
+ # Enable DOM domain first (Overlay depends on it)
88
+ self.cdp.execute("DOM.enable")
89
+
90
+ # Request document to establish DOM tree context
91
+ # REQUIRED: BackendNodeIds only work after getDocument() is called
92
+ self.cdp.execute("DOM.getDocument", {"depth": -1})
93
+
94
+ # Enable CSS domain (needed for computed styles)
95
+ self.cdp.execute("CSS.enable")
96
+
97
+ # Enable Overlay domain
98
+ self.cdp.execute("Overlay.enable")
99
+
100
+ # Set inspect mode with native Chrome highlighting
101
+ self.cdp.execute(
102
+ "Overlay.setInspectMode",
103
+ {
104
+ "mode": "searchForNode",
105
+ "highlightConfig": {
106
+ "showInfo": True,
107
+ "showStyles": True,
108
+ "contentColor": {"r": 111, "g": 168, "b": 220, "a": 0.66},
109
+ "paddingColor": {"r": 147, "g": 196, "b": 125, "a": 0.55},
110
+ "borderColor": {"r": 255, "g": 229, "b": 153, "a": 0.66},
111
+ "marginColor": {"r": 246, "g": 178, "b": 107, "a": 0.66},
112
+ },
113
+ },
114
+ )
115
+
116
+ self._inspection_active = True
117
+ logger.info("Element inspection mode enabled")
118
+
119
+ return {"success": True, "inspect_active": True}
120
+
121
+ except Exception as e:
122
+ logger.error(f"Failed to enable inspection mode: {e}")
123
+ return {"error": str(e)}
124
+
125
+ def stop_inspect(self) -> dict[str, Any]:
126
+ """Disable CDP element inspection mode.
127
+
128
+ Returns:
129
+ Success status dictionary.
130
+ """
131
+ if not self.cdp or not self.cdp.ws_app:
132
+ return {"error": "Not connected to page"}
133
+
134
+ if not self._inspection_active:
135
+ return {"success": True, "inspect_active": False}
136
+
137
+ try:
138
+ # Disable inspect mode
139
+ # NOTE: highlightConfig required even for mode=none, otherwise CDP throws:
140
+ # "Internal error: highlight configuration parameter is missing"
141
+ self.cdp.execute("Overlay.setInspectMode", {"mode": "none", "highlightConfig": {}})
142
+
143
+ self._inspection_active = False
144
+ logger.info("Element inspection mode disabled")
145
+
146
+ return {"success": True, "inspect_active": False}
147
+
148
+ except Exception as e:
149
+ logger.error(f"Failed to disable inspection mode: {e}")
150
+ return {"error": str(e)}
151
+
152
+ def handle_inspect_node_requested(self, event: dict) -> None:
153
+ """Handle Overlay.inspectNodeRequested event (user clicked element).
154
+
155
+ CRITICAL: Called from WebSocket thread - MUST NOT make blocking CDP calls!
156
+ Offload to background thread to avoid deadlock.
157
+
158
+ Args:
159
+ event: CDP event with method and params
160
+ """
161
+ if not self.cdp or not self.state:
162
+ logger.error("DOMService not properly initialized (missing cdp or state)")
163
+ return
164
+
165
+ params = event.get("params", {})
166
+ backend_node_id = params.get("backendNodeId")
167
+ if not backend_node_id:
168
+ logger.warning("inspectNodeRequested event missing backendNodeId")
169
+ return
170
+
171
+ # Increment pending counter (thread-safe)
172
+ with self._state_lock:
173
+ self._pending_selections += 1
174
+ self._trigger_broadcast()
175
+
176
+ # Submit to background thread - returns immediately, no blocking
177
+ self._executor.submit(self._process_node_selection, backend_node_id)
178
+
179
+ def handle_frame_navigated(self, event: dict) -> None:
180
+ """Handle Page.frameNavigated event (page navigation).
181
+
182
+ Clears selections when main frame navigates to keep state in sync with page.
183
+ Called from WebSocket thread - must be non-blocking.
184
+
185
+ Args:
186
+ event: CDP event with method and params
187
+ """
188
+ params = event.get("params", {})
189
+ frame = params.get("frame", {})
190
+
191
+ # Only clear on main frame navigation (not iframes)
192
+ if frame.get("parentId"):
193
+ return
194
+
195
+ logger.info("Main frame navigated - clearing selections")
196
+ self.clear_selections()
197
+ self._trigger_broadcast()
198
+
199
+ def _process_node_selection(self, backend_node_id: int) -> None:
200
+ """Process node selection in background thread.
201
+
202
+ Safe to make blocking CDP calls here - we're not in WebSocket thread.
203
+
204
+ Args:
205
+ backend_node_id: CDP backend node ID from inspectNodeRequested event
206
+ """
207
+ try:
208
+ # Make blocking CDP calls (OK in background thread)
209
+ data = self._extract_node_data(backend_node_id)
210
+
211
+ # Thread-safe state update
212
+ with self._state_lock:
213
+ if not self.state:
214
+ logger.error("DOMService state not initialized")
215
+ return
216
+
217
+ selection_id = str(self._next_id)
218
+ self._next_id += 1
219
+
220
+ if not self.state.browser_data:
221
+ self.state.browser_data = {"selections": {}, "prompt": ""}
222
+ if "selections" not in self.state.browser_data:
223
+ self.state.browser_data["selections"] = {}
224
+
225
+ self.state.browser_data["selections"][selection_id] = data
226
+
227
+ logger.info(f"Element selected: {selection_id} - {data.get('preview', {}).get('tag', 'unknown')}")
228
+
229
+ except Exception as e:
230
+ logger.error(f"Failed to process node selection: {e}")
231
+ # Set error state for UI display
232
+ if self.state:
233
+ import time
234
+
235
+ error_msg = str(e)
236
+ # Provide user-friendly message for common errors
237
+ if "timed out" in error_msg.lower() or isinstance(e, TimeoutError):
238
+ error_msg = "Element selection timed out - page may be unresponsive"
239
+ self.state.error_state = {"message": error_msg, "timestamp": time.time()}
240
+ finally:
241
+ # Decrement pending counter (thread-safe)
242
+ with self._state_lock:
243
+ self._pending_selections -= 1
244
+ self._trigger_broadcast()
245
+
246
+ def _trigger_broadcast(self) -> None:
247
+ """Trigger SSE broadcast via queue (thread-safe helper)."""
248
+ if self._broadcast_queue:
249
+ try:
250
+ self._broadcast_queue.put_nowait({"type": "dom_update"})
251
+ except Exception as e:
252
+ logger.debug(f"Failed to queue broadcast: {e}")
253
+
254
+ def _extract_node_data(self, backend_node_id: int) -> dict[str, Any]:
255
+ """Extract complete element data via CDP.
256
+
257
+ Args:
258
+ backend_node_id: CDP backend node ID from inspectNodeRequested event
259
+
260
+ Returns:
261
+ Dictionary with element data compatible with browser_data schema
262
+
263
+ Raises:
264
+ RuntimeError: If CDP is not connected or commands fail
265
+ TimeoutError: If CDP commands timeout (page busy, heavy load)
266
+ """
267
+ if not self.cdp:
268
+ raise RuntimeError("CDP session not initialized")
269
+
270
+ # Use 15s timeout for interactive operations (balanced between responsiveness and heavy pages)
271
+ # Still shorter than default 30s to provide faster failure feedback
272
+ timeout = 15.0
273
+
274
+ try:
275
+ # Describe node directly with backendNodeId (no need for resolveNode first!)
276
+ describe_result = self.cdp.execute("DOM.describeNode", {"backendNodeId": backend_node_id}, timeout=timeout)
277
+
278
+ if "node" not in describe_result:
279
+ raise RuntimeError(f"Failed to describe node {backend_node_id}")
280
+
281
+ node = describe_result["node"]
282
+ node_id = node["nodeId"]
283
+
284
+ # Get outer HTML
285
+ html_result = self.cdp.execute("DOM.getOuterHTML", {"nodeId": node_id}, timeout=timeout)
286
+ outer_html = html_result.get("outerHTML", "")
287
+
288
+ # Get computed styles
289
+ styles_result = self.cdp.execute("CSS.getComputedStyleForNode", {"nodeId": node_id}, timeout=timeout)
290
+
291
+ # Convert styles to dict
292
+ styles = {}
293
+ for prop in styles_result.get("computedStyle", []):
294
+ styles[prop["name"]] = prop["value"]
295
+
296
+ # Get box model for badge positioning
297
+ try:
298
+ box_result = self.cdp.execute("DOM.getBoxModel", {"nodeId": node_id}, timeout=timeout)
299
+ # Use top-left corner of content box
300
+ content_box = box_result["model"]["content"]
301
+ badge_x = int(content_box[0]) # Top-left x
302
+ badge_y = int(content_box[1]) # Top-left y
303
+ except Exception:
304
+ # Fallback if element has no box model (display: none, etc.)
305
+ badge_x = 0
306
+ badge_y = 0
307
+
308
+ except TimeoutError as e:
309
+ logger.warning(f"Timeout extracting node {backend_node_id}: {e}")
310
+ raise RuntimeError("Element selection timed out - page may be busy or unresponsive") from e
311
+
312
+ # Generate CSS selector
313
+ css_selector = self._generate_css_selector(node)
314
+
315
+ # Generate XPath
316
+ xpath = self._generate_xpath(node)
317
+
318
+ # Generate jsPath (for js() command integration)
319
+ js_path = f"document.querySelector('{css_selector}')"
320
+
321
+ # Build preview
322
+ tag = node.get("nodeName", "").lower()
323
+ node_attrs = node.get("attributes", [])
324
+ attrs_dict = {}
325
+ for i in range(0, len(node_attrs), 2):
326
+ if i + 1 < len(node_attrs):
327
+ attrs_dict[node_attrs[i]] = node_attrs[i + 1]
328
+
329
+ preview = {
330
+ "tag": tag,
331
+ "id": attrs_dict.get("id", ""),
332
+ "classes": attrs_dict.get("class", "").split() if attrs_dict.get("class") else [],
333
+ "text": self._get_node_text(outer_html)[:100], # First 100 chars
334
+ }
335
+
336
+ # Build complete data structure (compatible with existing schema)
337
+ return {
338
+ "outerHTML": outer_html,
339
+ "selector": css_selector,
340
+ "jsPath": js_path,
341
+ "styles": styles,
342
+ "xpath": xpath,
343
+ "fullXpath": xpath, # CDP doesn't distinguish, use same
344
+ "preview": preview,
345
+ "badge": {"x": badge_x, "y": badge_y},
346
+ "nodeId": node_id,
347
+ "backendNodeId": backend_node_id,
348
+ }
349
+
350
+ def _generate_css_selector(self, node: dict) -> str:
351
+ """Generate unique CSS selector for node.
352
+
353
+ Uses a combination of strategies to ensure uniqueness:
354
+ 1. ID if available (most unique)
355
+ 2. Tag + classes + nth-child for specificity
356
+ 3. Falls back to full path if needed
357
+
358
+ Args:
359
+ node: CDP node description
360
+
361
+ Returns:
362
+ CSS selector string
363
+ """
364
+ # Parse attributes
365
+ attrs_dict = self._parse_node_attributes(node)
366
+
367
+ # Strategy 1: ID selector (unique by definition)
368
+ if "id" in attrs_dict and attrs_dict["id"]:
369
+ return f"#{attrs_dict['id']}"
370
+
371
+ # Strategy 2: Build selector with tag + classes + nth-child
372
+ tag = node.get("nodeName", "").lower()
373
+ selector = tag
374
+
375
+ # Add first 2 classes for specificity without being too brittle
376
+ if "class" in attrs_dict and attrs_dict["class"]:
377
+ classes = attrs_dict["class"].split()[:2]
378
+ if classes:
379
+ selector += "." + ".".join(classes)
380
+
381
+ # Add nth-child for uniqueness within parent
382
+ # This is key to distinguishing elements with same tag/class
383
+ parent_id = node.get("parentId")
384
+ if parent_id and self.cdp:
385
+ try:
386
+ # Get parent node to count children
387
+ parent_result = self.cdp.execute("DOM.describeNode", {"nodeId": parent_id}, timeout=5.0)
388
+
389
+ if "node" in parent_result:
390
+ parent_node = parent_result["node"]
391
+ child_node_ids = parent_node.get("childNodeIds", [])
392
+
393
+ # Find our position among siblings
394
+ node_id = node.get("nodeId")
395
+ if node_id in child_node_ids:
396
+ nth = child_node_ids.index(node_id) + 1
397
+ selector += f":nth-child({nth})"
398
+
399
+ except Exception as e:
400
+ logger.debug(f"Could not add nth-child to selector: {e}")
401
+
402
+ return selector
403
+
404
+ def _parse_node_attributes(self, node: dict) -> dict:
405
+ """Parse CDP node attributes array into dictionary.
406
+
407
+ Args:
408
+ node: CDP node with attributes array [name1, value1, name2, value2, ...]
409
+
410
+ Returns:
411
+ Dictionary of {name: value}
412
+ """
413
+ attrs = node.get("attributes", [])
414
+ attrs_dict = {}
415
+ for i in range(0, len(attrs), 2):
416
+ if i + 1 < len(attrs):
417
+ attrs_dict[attrs[i]] = attrs[i + 1]
418
+ return attrs_dict
419
+
420
+ def _generate_xpath(self, node: dict) -> str:
421
+ """Generate XPath for node.
422
+
423
+ Args:
424
+ node: CDP node description
425
+
426
+ Returns:
427
+ XPath string
428
+ """
429
+ tag = node.get("nodeName", "").lower()
430
+ attrs_dict = self._parse_node_attributes(node)
431
+
432
+ # Prefer ID (unique)
433
+ if "id" in attrs_dict and attrs_dict["id"]:
434
+ return f"//{tag}[@id='{attrs_dict['id']}']"
435
+
436
+ # Use class attribute if available
437
+ if "class" in attrs_dict and attrs_dict["class"]:
438
+ # XPath class matching (contains all classes)
439
+ classes = attrs_dict["class"].split()
440
+ if classes:
441
+ return f"//{tag}[@class='{attrs_dict['class']}']"
442
+
443
+ # Fallback to tag only
444
+ return f"//{tag}"
445
+
446
+ def _get_node_text(self, html: str) -> str:
447
+ """Extract text content from HTML (simple implementation).
448
+
449
+ Args:
450
+ html: Outer HTML string
451
+
452
+ Returns:
453
+ Extracted text content
454
+ """
455
+ # Simple regex to strip tags
456
+ text = re.sub(r"<[^>]+>", "", html)
457
+ return text.strip()
458
+
459
+ def get_state(self) -> dict[str, Any]:
460
+ """Get current DOM service state (thread-safe).
461
+
462
+ Returns:
463
+ State dictionary with inspect_active, selections, and pending count
464
+ """
465
+ # Thread-safe read: protect against concurrent writes from WebSocket thread
466
+ with self._state_lock:
467
+ selections = {}
468
+ prompt = ""
469
+
470
+ if self.state is not None and self.state.browser_data:
471
+ # Deep copy to prevent mutations during SSE broadcast
472
+ selections = dict(self.state.browser_data.get("selections", {}))
473
+ prompt = self.state.browser_data.get("prompt", "")
474
+
475
+ return {
476
+ "inspect_active": self._inspection_active,
477
+ "selections": selections,
478
+ "prompt": prompt,
479
+ "pending_count": self._pending_selections, # For progress indicator
480
+ }
481
+
482
+ def clear_selections(self) -> None:
483
+ """Clear all selections (thread-safe)."""
484
+ with self._state_lock:
485
+ if self.state is not None and self.state.browser_data:
486
+ self.state.browser_data["selections"] = {}
487
+ self._next_id = 1
488
+ logger.info("Selections cleared")
489
+
490
+ def cleanup(self) -> None:
491
+ """Cleanup resources (executor, callbacks).
492
+
493
+ Call this before disconnect or app exit.
494
+ """
495
+ # Shutdown executor - wait=False to avoid blocking on stuck tasks
496
+ # cancel_futures=True prevents hanging on incomplete selections (Python 3.9+)
497
+ if hasattr(self, "_executor"):
498
+ try:
499
+ self._executor.shutdown(wait=False, cancel_futures=True)
500
+ logger.info("ThreadPoolExecutor shut down")
501
+ except Exception as e:
502
+ logger.debug(f"Executor shutdown error (non-fatal): {e}")
503
+
504
+ # Clear inspection state
505
+ if self._inspection_active:
506
+ try:
507
+ self.stop_inspect()
508
+ except Exception as e:
509
+ logger.debug(f"Failed to stop inspect on cleanup: {e}")
510
+
511
+
512
+ __all__ = ["DOMService"]